#!/usr/bin/env python3
"""
sipi_world_servidor.py - Servidor de SiPi World.

SiPi World es la parte "social" de SiPi: una cuenta, un chat global,
comunidades que cualquiera puede crear/unirse, y proyectos para armar
dentro de esas comunidades con gente cerca o lejos.

Que es esto tecnicamente (para ser honesto sobre el alcance): un
servidor TCP con un protocolo propio muy simple (una linea de JSON por
mensaje), con cuentas y mensajes guardados en SQLite. Es exactamente el
mismo tipo de arquitectura cliente-servidor que usa cualquier chat
(IRC, XMPP, Discord por dentro, etc.): una PC (o una compu en un
servidor/VPS) corre este proceso escuchando en un puerto, y cualquiera
que se pueda conectar a ese host:puerto -- por red local, por internet
si el puerto esta abierto/redirigido, o alquilando un servidor barato
en la nube -- puede chatear con el resto. Eso es lo que en la practica
permite hablar "de cerca" (misma red WiFi, host = 127.0.0.1 o la IP
local), "de lejos" (mismo pais, otro pais, intercontinental: mismo
protocolo, la unica diferencia es DONDE esta corriendo el servidor y
que tan lejos viaja la conexion) -- no hay nada que este servidor deba
hacer distinto segun la distancia, TCP/IP ya resuelve eso. Lo que este
archivo NO trae de fabrica es un servidor publico ya hosteado con una
direccion fija -- correrlo en una sola PC de alguien alcanza para
probarlo con amigos en la misma red ahora mismo; para que funcione
"para cualquiera en cualquier lado" sin coordinar quien prende su PC,
alguien con autoridad sobre el proyecto SiPi tiene que dejarlo corriendo
en un servidor real con un dominio o IP fija (un VPS de $5/mes ya
alcanza) -- esa parte es infraestructura, no codigo, y no puedo
inventarla desde este entorno.

Uso:
    python3 sipi_world_servidor.py                  # escucha en 0.0.0.0:8765
    python3 sipi_world_servidor.py --puerto 9000
    python3 sipi_world_servidor.py --db mi_mundo.sqlite3

Protocolo (una linea de JSON por mensaje, en ambas direcciones):
    Cliente -> Servidor:  {"accion": "...", "id_peticion": "...", ...}
    Servidor -> Cliente:  {"tipo": "respuesta", "id_peticion": "...", "ok": true/false, ...}
                       o:  {"tipo": "evento", "evento": "...", ...}  (sin pedirlo, ej. mensaje nuevo)

Acciones soportadas: registrar, iniciar_sesion, chat_global_enviar,
chat_global_historial, comunidad_crear, comunidad_listar,
comunidad_unirse, comunidad_info, comunidad_chat_enviar,
comunidad_chat_historial, proyecto_crear, proyecto_listar,
proyecto_actualizar_estado, proyecto_obtener_codigo,
proyecto_guardar_codigo.

Codigo SiPi compartido por proyecto: ademas de nombre/descripcion/
estado, cada proyecto puede guardar el codigo SiPi real del programa
(`codigo_sipi`, texto plano) y opcionalmente una contraseña propia
(distinta de la clave de la cuenta) para leerlo/escribirlo -- asi un
amigo te puede pasar simplemente el ID del proyecto y esa contraseña,
vos entras a la misma comunidad (o ya sos miembro), pedis el codigo con
esos dos datos (`proyecto_obtener_codigo`), lo editas en tu editor SiPi
local, y lo volves a subir con `proyecto_guardar_codigo` -- sin tener
que mandarse el archivo .sipi por otro lado. Si el proyecto se crea sin
contraseña, cualquier miembro de la comunidad puede leer/escribir el
codigo directamente (pensado para trabajo colaborativo abierto dentro
de la comunidad); con contraseña, hace falta conocerla ademas de ser
miembro (pensado para compartir solo con quien te pasó esos dos datos).
La contraseña del proyecto se guarda hasheada igual que las cuentas
(PBKDF2-HMAC-SHA256, nunca en texto plano) y es independiente del login
de la cuenta -- perder la contraseña del proyecto no implica poder
recuperarla, ya que ni el servidor la guarda en claro.
"""
import argparse
import hashlib
import hmac
import json
import os
import secrets
import socket
import sqlite3
import threading
import time


PUERTO_POR_DEFECTO = 8765
VERSION_PROTOCOLO = 1
LIMITE_MAXIMO_HISTORIAL = 500  # tope duro para 'limite' en *_historial -- evita que un cliente pida traer miles de mensajes de una y sature RAM/red


# ---------------------------------------------------------------- utilidades
def _hash_clave(clave, sal=None):
    """PBKDF2-HMAC-SHA256, 200k iteraciones -- misma familia de algoritmo que
    usan Django/Werkzeug para contraseñas, sin depender de ninguna libreria
    externa (solo hashlib de la biblioteca estandar)."""
    sal = sal or secrets.token_hex(16)
    derivado = hashlib.pbkdf2_hmac("sha256", clave.encode("utf-8"), sal.encode("utf-8"), 200_000)
    return sal, derivado.hex()


def _verificar_clave(clave, sal, hash_guardado):
    _, calculado = _hash_clave(clave, sal)
    return hmac.compare_digest(calculado, hash_guardado)


class ErrorSiPiWorld(Exception):
    """Error de negocio (usuario ya existe, comunidad no encontrada, etc.)
    -- se distingue de un error de programacion para poder devolverle al
    cliente un mensaje claro en vez de un stacktrace."""


# ---------------------------------------------------------------- base de datos
class BaseDatos:
    def __init__(self, ruta):
        self.ruta = ruta
        self._local = threading.local()
        self._lock = threading.Lock()
        self._inicializar_esquema()

    def _conexion(self):
        # Una conexion sqlite3 por hilo (el servidor atiende cada cliente
        # en su propio hilo) -- sqlite3 no permite compartir una conexion
        # entre hilos sin check_same_thread=False, y es mas simple/seguro
        # darle una conexion propia a cada hilo que lidiar con eso.
        if not hasattr(self._local, "con"):
            # timeout=30: si dos hilos escriben a la vez, sqlite espera hasta
            # 30s en vez de tirar "database is locked" al toque -- con pocos
            # usuarios casi nunca hace falta esperar, pero evita errores
            # espurios bajo una rafaga de mensajes simultaneos.
            self._local.con = sqlite3.connect(self.ruta, timeout=30)
            self._local.con.row_factory = sqlite3.Row
            self._local.con.execute("PRAGMA foreign_keys = ON")
            # WAL: los lectores no bloquean a los escritores (ni viceversa),
            # asi que alguien leyendo el historial del chat no frena a otro
            # mandando un mensaje -- clave para no sentir "trabas" con varias
            # personas conectadas a la vez desde una PC hogareña.
            self._local.con.execute("PRAGMA journal_mode = WAL")
            # synchronous=NORMAL (en vez del FULL por defecto): sigue siendo
            # seguro con WAL (SQLite lo recomienda para este modo) y reduce
            # muchisimo los fsync a disco -- menos desgaste, menos latencia
            # por mensaje, sin arriesgar corrupcion de la base.
            self._local.con.execute("PRAGMA synchronous = NORMAL")
        return self._local.con

    def _inicializar_esquema(self):
        con = sqlite3.connect(self.ruta)
        con.execute("PRAGMA journal_mode = WAL")
        con.executescript(
            """
            CREATE TABLE IF NOT EXISTS usuarios (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                nombre TEXT UNIQUE NOT NULL,
                sal TEXT NOT NULL,
                hash_clave TEXT NOT NULL,
                creado_en REAL NOT NULL
            );
            CREATE TABLE IF NOT EXISTS comunidades (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                nombre TEXT NOT NULL,
                descripcion TEXT NOT NULL DEFAULT '',
                codigo_invitacion TEXT UNIQUE NOT NULL,
                creador_id INTEGER NOT NULL REFERENCES usuarios(id),
                creado_en REAL NOT NULL
            );
            CREATE TABLE IF NOT EXISTS miembros_comunidad (
                comunidad_id INTEGER NOT NULL REFERENCES comunidades(id),
                usuario_id INTEGER NOT NULL REFERENCES usuarios(id),
                unido_en REAL NOT NULL,
                PRIMARY KEY (comunidad_id, usuario_id)
            );
            CREATE TABLE IF NOT EXISTS mensajes_globales (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                usuario_id INTEGER NOT NULL REFERENCES usuarios(id),
                texto TEXT NOT NULL,
                enviado_en REAL NOT NULL
            );
            CREATE TABLE IF NOT EXISTS mensajes_comunidad (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                comunidad_id INTEGER NOT NULL REFERENCES comunidades(id),
                usuario_id INTEGER NOT NULL REFERENCES usuarios(id),
                texto TEXT NOT NULL,
                enviado_en REAL NOT NULL
            );
            CREATE TABLE IF NOT EXISTS proyectos (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                comunidad_id INTEGER NOT NULL REFERENCES comunidades(id),
                nombre TEXT NOT NULL,
                descripcion TEXT NOT NULL DEFAULT '',
                creador_id INTEGER NOT NULL REFERENCES usuarios(id),
                estado TEXT NOT NULL DEFAULT 'propuesto',
                creado_en REAL NOT NULL,
                codigo_sipi TEXT,
                codigo_sal TEXT,
                codigo_hash_clave TEXT,
                codigo_actualizado_en REAL
            );

            -- Indices para que las consultas mas frecuentes (historial de
            -- chat, listar comunidades/proyectos de un usuario, unirse por
            -- codigo) no tengan que recorrer la tabla entera a medida que
            -- crece -- sin esto, con meses de historial cada mensaje nuevo
            -- se pone mas lento de listar.
            CREATE INDEX IF NOT EXISTS idx_mensajes_globales_id
                ON mensajes_globales (id DESC);
            CREATE INDEX IF NOT EXISTS idx_mensajes_comunidad_comunidad_id
                ON mensajes_comunidad (comunidad_id, id DESC);
            CREATE INDEX IF NOT EXISTS idx_miembros_usuario
                ON miembros_comunidad (usuario_id);
            CREATE INDEX IF NOT EXISTS idx_proyectos_comunidad
                ON proyectos (comunidad_id, creado_en DESC);
            """
        )
        con.commit()
        # Migracion suave para bases de datos ya existentes creadas antes de
        # que 'proyectos' tuviera columnas de codigo compartido (sqlite no
        # tiene "ADD COLUMN IF NOT EXISTS", asi que se chequea a mano).
        columnas = {fila[1] for fila in con.execute("PRAGMA table_info(proyectos)").fetchall()}
        columnas_nuevas = {
            "codigo_sipi": "TEXT",
            "codigo_sal": "TEXT",
            "codigo_hash_clave": "TEXT",
            "codigo_actualizado_en": "REAL",
        }
        for columna, tipo in columnas_nuevas.items():
            if columna not in columnas:
                con.execute(f"ALTER TABLE proyectos ADD COLUMN {columna} {tipo}")
        con.commit()
        con.close()

    # ---- usuarios ----
    def crear_usuario(self, nombre, clave):
        nombre = nombre.strip()
        if not (2 <= len(nombre) <= 24):
            raise ErrorSiPiWorld("El nombre de usuario debe tener entre 2 y 24 caracteres.")
        if not (4 <= len(clave) <= 128):
            raise ErrorSiPiWorld("La clave debe tener al menos 4 caracteres.")
        sal, hash_clave = _hash_clave(clave)
        con = self._conexion()
        with self._lock:
            try:
                cur = con.execute(
                    "INSERT INTO usuarios (nombre, sal, hash_clave, creado_en) VALUES (?, ?, ?, ?)",
                    (nombre, sal, hash_clave, time.time()),
                )
                con.commit()
            except sqlite3.IntegrityError:
                raise ErrorSiPiWorld(f"El nombre de usuario '{nombre}' ya esta en uso.")
        return cur.lastrowid

    def verificar_usuario(self, nombre, clave):
        con = self._conexion()
        fila = con.execute("SELECT * FROM usuarios WHERE nombre = ?", (nombre,)).fetchone()
        if fila is None or not _verificar_clave(clave, fila["sal"], fila["hash_clave"]):
            raise ErrorSiPiWorld("Usuario o clave incorrectos.")
        return fila["id"]

    def nombre_usuario(self, usuario_id):
        con = self._conexion()
        fila = con.execute("SELECT nombre FROM usuarios WHERE id = ?", (usuario_id,)).fetchone()
        return fila["nombre"] if fila else "???"

    # ---- chat global ----
    def guardar_mensaje_global(self, usuario_id, texto):
        con = self._conexion()
        with self._lock:
            con.execute(
                "INSERT INTO mensajes_globales (usuario_id, texto, enviado_en) VALUES (?, ?, ?)",
                (usuario_id, texto, time.time()),
            )
            con.commit()

    def historial_global(self, limite=50):
        con = self._conexion()
        filas = con.execute(
            """SELECT m.texto, m.enviado_en, u.nombre AS autor
               FROM mensajes_globales m JOIN usuarios u ON u.id = m.usuario_id
               ORDER BY m.id DESC LIMIT ?""",
            (limite,),
        ).fetchall()
        return [dict(f) for f in reversed(filas)]

    # ---- comunidades ----
    def crear_comunidad(self, nombre, descripcion, creador_id):
        nombre = nombre.strip()
        if not (2 <= len(nombre) <= 50):
            raise ErrorSiPiWorld("El nombre de la comunidad debe tener entre 2 y 50 caracteres.")
        codigo = secrets.token_hex(4).upper()
        con = self._conexion()
        with self._lock:
            cur = con.execute(
                "INSERT INTO comunidades (nombre, descripcion, codigo_invitacion, creador_id, creado_en) "
                "VALUES (?, ?, ?, ?, ?)",
                (nombre, descripcion.strip(), codigo, creador_id, time.time()),
            )
            comunidad_id = cur.lastrowid
            con.execute(
                "INSERT INTO miembros_comunidad (comunidad_id, usuario_id, unido_en) VALUES (?, ?, ?)",
                (comunidad_id, creador_id, time.time()),
            )
            con.commit()
        return comunidad_id, codigo

    def unirse_comunidad(self, codigo_invitacion, usuario_id):
        con = self._conexion()
        fila = con.execute(
            "SELECT id, nombre FROM comunidades WHERE codigo_invitacion = ?", (codigo_invitacion.strip().upper(),)
        ).fetchone()
        if fila is None:
            raise ErrorSiPiWorld("No existe ninguna comunidad con ese codigo de invitacion.")
        with self._lock:
            con.execute(
                "INSERT OR IGNORE INTO miembros_comunidad (comunidad_id, usuario_id, unido_en) VALUES (?, ?, ?)",
                (fila["id"], usuario_id, time.time()),
            )
            con.commit()
        return fila["id"], fila["nombre"]

    def comunidades_de(self, usuario_id):
        con = self._conexion()
        filas = con.execute(
            """SELECT c.id, c.nombre, c.descripcion, c.codigo_invitacion,
                      (SELECT COUNT(*) FROM miembros_comunidad m2 WHERE m2.comunidad_id = c.id) AS miembros
               FROM comunidades c
               JOIN miembros_comunidad m ON m.comunidad_id = c.id
               WHERE m.usuario_id = ? ORDER BY c.nombre""",
            (usuario_id,),
        ).fetchall()
        return [dict(f) for f in filas]

    def es_miembro(self, comunidad_id, usuario_id):
        con = self._conexion()
        fila = con.execute(
            "SELECT 1 FROM miembros_comunidad WHERE comunidad_id = ? AND usuario_id = ?",
            (comunidad_id, usuario_id),
        ).fetchone()
        return fila is not None

    def guardar_mensaje_comunidad(self, comunidad_id, usuario_id, texto):
        con = self._conexion()
        with self._lock:
            con.execute(
                "INSERT INTO mensajes_comunidad (comunidad_id, usuario_id, texto, enviado_en) VALUES (?, ?, ?, ?)",
                (comunidad_id, usuario_id, texto, time.time()),
            )
            con.commit()

    def historial_comunidad(self, comunidad_id, limite=50):
        con = self._conexion()
        filas = con.execute(
            """SELECT m.texto, m.enviado_en, u.nombre AS autor
               FROM mensajes_comunidad m JOIN usuarios u ON u.id = m.usuario_id
               WHERE m.comunidad_id = ? ORDER BY m.id DESC LIMIT ?""",
            (comunidad_id, limite),
        ).fetchall()
        return [dict(f) for f in reversed(filas)]

    # ---- proyectos ----
    ESTADOS_PROYECTO = ("propuesto", "en_progreso", "terminado")

    def crear_proyecto(self, comunidad_id, nombre, descripcion, creador_id, clave_acceso=""):
        nombre = nombre.strip()
        if not (2 <= len(nombre) <= 80):
            raise ErrorSiPiWorld("El nombre del proyecto debe tener entre 2 y 80 caracteres.")
        clave_acceso = (clave_acceso or "").strip()
        if clave_acceso and not (4 <= len(clave_acceso) <= 128):
            raise ErrorSiPiWorld("La contraseña del proyecto debe tener al menos 4 caracteres (o dejarla vacia).")
        sal = hash_clave = None
        if clave_acceso:
            sal, hash_clave = _hash_clave(clave_acceso)
        con = self._conexion()
        with self._lock:
            cur = con.execute(
                "INSERT INTO proyectos (comunidad_id, nombre, descripcion, creador_id, estado, creado_en, "
                "codigo_sal, codigo_hash_clave) VALUES (?, ?, ?, ?, 'propuesto', ?, ?, ?)",
                (comunidad_id, nombre, descripcion.strip(), creador_id, time.time(), sal, hash_clave),
            )
            con.commit()
        return cur.lastrowid

    def _proyecto_de(self, proyecto_id, comunidad_id):
        con = self._conexion()
        fila = con.execute(
            "SELECT * FROM proyectos WHERE id = ? AND comunidad_id = ?", (proyecto_id, comunidad_id)
        ).fetchone()
        if fila is None:
            raise ErrorSiPiWorld("Ese proyecto no existe en esta comunidad.")
        return fila

    def _verificar_clave_proyecto(self, fila_proyecto, clave_acceso):
        """Si el proyecto se creo con contraseña, la exige y la valida. Si se
        creo sin contraseña (codigo_hash_clave es NULL), cualquier miembro de
        la comunidad puede leer/escribir el codigo sin pedir clave."""
        if fila_proyecto["codigo_hash_clave"] is None:
            return
        if not _verificar_clave(clave_acceso or "", fila_proyecto["codigo_sal"], fila_proyecto["codigo_hash_clave"]):
            raise ErrorSiPiWorld("Contraseña del proyecto incorrecta.")

    def obtener_codigo_proyecto(self, proyecto_id, comunidad_id, clave_acceso):
        fila = self._proyecto_de(proyecto_id, comunidad_id)
        self._verificar_clave_proyecto(fila, clave_acceso)
        return {
            "codigo_sipi": fila["codigo_sipi"] or "",
            "nombre": fila["nombre"],
            "actualizado_en": fila["codigo_actualizado_en"],
            "tiene_clave": fila["codigo_hash_clave"] is not None,
        }

    def guardar_codigo_proyecto(self, proyecto_id, comunidad_id, clave_acceso, codigo_sipi):
        fila = self._proyecto_de(proyecto_id, comunidad_id)
        self._verificar_clave_proyecto(fila, clave_acceso)
        if len(codigo_sipi) > 2_000_000:
            raise ErrorSiPiWorld("El codigo es demasiado grande (limite: 2 MB).")
        con = self._conexion()
        with self._lock:
            con.execute(
                "UPDATE proyectos SET codigo_sipi = ?, codigo_actualizado_en = ? WHERE id = ? AND comunidad_id = ?",
                (codigo_sipi, time.time(), proyecto_id, comunidad_id),
            )
            con.commit()

    def listar_proyectos(self, comunidad_id):
        con = self._conexion()
        filas = con.execute(
            """SELECT p.id, p.nombre, p.descripcion, p.estado, p.creado_en, u.nombre AS creador,
                      (p.codigo_sipi IS NOT NULL) AS tiene_codigo,
                      (p.codigo_hash_clave IS NOT NULL) AS tiene_clave,
                      p.codigo_actualizado_en
               FROM proyectos p JOIN usuarios u ON u.id = p.creador_id
               WHERE p.comunidad_id = ? ORDER BY p.creado_en DESC""",
            (comunidad_id,),
        ).fetchall()
        resultado = []
        for f in filas:
            d = dict(f)
            d["tiene_codigo"] = bool(d["tiene_codigo"])
            d["tiene_clave"] = bool(d["tiene_clave"])
            resultado.append(d)
        return resultado

    def actualizar_estado_proyecto(self, proyecto_id, comunidad_id, estado):
        if estado not in self.ESTADOS_PROYECTO:
            raise ErrorSiPiWorld(f"Estado invalido. Debe ser uno de: {', '.join(self.ESTADOS_PROYECTO)}")
        con = self._conexion()
        with self._lock:
            cur = con.execute(
                "UPDATE proyectos SET estado = ? WHERE id = ? AND comunidad_id = ?",
                (estado, proyecto_id, comunidad_id),
            )
            con.commit()
        if cur.rowcount == 0:
            raise ErrorSiPiWorld("Ese proyecto no existe en esta comunidad.")


# ---------------------------------------------------------------- servidor
TAMANO_MAXIMO_LINEA = 4 * 1024 * 1024  # 4 MB por mensaje (de sobra para el codigo_sipi mas grande permitido, 2 MB, mas margen de JSON) -- evita que una conexion con bug o maliciosa agote la RAM mandando una linea gigante sin fin.


def _activar_keepalive(sock):
    """Prende el keepalive TCP del sistema operativo: si un cliente se
    queda colgado sin cerrar prolijo la conexion (se corta el wifi, la
    notebook se duerme, se cierra a la fuerza), el SO detecta la conexion
    muerta y el hilo correspondiente se libera solo en vez de quedar
    esperando datos para siempre -- importante para un servidor que va a
    quedar prendido dias/semanas con conexiones entrando y saliendo."""
    try:
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
        # Los parametros finos (cada cuanto sondear, cuantos intentos) no
        # existen en todos los sistemas operativos (si en Linux, no en
        # Windows/Mac por default) -- se ponen si estan disponibles y se
        # ignora silenciosamente si no, el keepalive basico ya alcanza.
        if hasattr(socket, "TCP_KEEPIDLE"):
            sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
        if hasattr(socket, "TCP_KEEPINTVL"):
            sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 15)
        if hasattr(socket, "TCP_KEEPCNT"):
            sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4)
    except OSError:
        pass  # plataforma sin soporte para alguna de estas opciones -- no es fatal


class ManejadorCliente(threading.Thread):
    def __init__(self, conexion, direccion, servidor):
        super().__init__(daemon=True)
        self.conexion = conexion
        self.direccion = direccion
        self.servidor = servidor
        self.usuario_id = None
        self.nombre_usuario = None
        self.comunidades_suscritas = set()
        _activar_keepalive(conexion)
        self._archivo = conexion.makefile("rwb")

    def run(self):
        with self.servidor._lock_clientes:
            self.servidor.clientes.add(self)
        try:
            while True:
                linea = self._archivo.readline(TAMANO_MAXIMO_LINEA + 1)
                if not linea:
                    break
                if len(linea) > TAMANO_MAXIMO_LINEA:
                    self._enviar({"tipo": "respuesta", "ok": False,
                                   "error": "Mensaje demasiado grande (limite 4 MB)."})
                    break  # conexion que manda algo tan grande no es de confiar, se corta
                try:
                    peticion = json.loads(linea.decode("utf-8"))
                except (json.JSONDecodeError, UnicodeDecodeError):
                    self._enviar({"tipo": "respuesta", "ok": False, "error": "JSON invalido."})
                    continue
                self._procesar(peticion)
        except (ConnectionResetError, ConnectionAbortedError, OSError):
            pass
        finally:
            with self.servidor._lock_clientes:
                self.servidor.clientes.discard(self)
            try:
                self.conexion.close()
            except OSError:
                pass

    def _enviar(self, mensaje):
        try:
            self._archivo.write((json.dumps(mensaje, ensure_ascii=False) + "\n").encode("utf-8"))
            self._archivo.flush()
        except OSError:
            pass

    def _requerir_sesion(self):
        if self.usuario_id is None:
            raise ErrorSiPiWorld("Primero hay que iniciar sesion (o registrarse).")

    def _procesar(self, peticion):
        accion = peticion.get("accion")
        id_peticion = peticion.get("id_peticion")
        db = self.servidor.db
        try:
            if accion == "registrar":
                usuario_id = db.crear_usuario(peticion.get("usuario", ""), peticion.get("clave", ""))
                self.usuario_id = usuario_id
                self.nombre_usuario = peticion.get("usuario", "").strip()
                resultado = {"usuario_id": usuario_id, "usuario": self.nombre_usuario}

            elif accion == "iniciar_sesion":
                usuario_id = db.verificar_usuario(peticion.get("usuario", ""), peticion.get("clave", ""))
                self.usuario_id = usuario_id
                self.nombre_usuario = db.nombre_usuario(usuario_id)
                resultado = {"usuario_id": usuario_id, "usuario": self.nombre_usuario}

            elif accion == "chat_global_enviar":
                self._requerir_sesion()
                texto = str(peticion.get("texto", "")).strip()[:2000]
                if not texto:
                    raise ErrorSiPiWorld("El mensaje esta vacio.")
                db.guardar_mensaje_global(self.usuario_id, texto)
                self.servidor.difundir_global({
                    "tipo": "evento", "evento": "mensaje_global",
                    "autor": self.nombre_usuario, "texto": texto, "enviado_en": time.time(),
                }, excluir=self)
                resultado = {}

            elif accion == "chat_global_historial":
                self._requerir_sesion()
                limite = min(int(peticion.get("limite", 50)), LIMITE_MAXIMO_HISTORIAL)
                resultado = {"mensajes": db.historial_global(limite)}

            elif accion == "comunidad_crear":
                self._requerir_sesion()
                comunidad_id, codigo = db.crear_comunidad(
                    peticion.get("nombre", ""), peticion.get("descripcion", ""), self.usuario_id)
                self.comunidades_suscritas.add(comunidad_id)
                resultado = {"id": comunidad_id, "codigo_invitacion": codigo}

            elif accion == "comunidad_unirse":
                self._requerir_sesion()
                comunidad_id, nombre = db.unirse_comunidad(peticion.get("codigo_invitacion", ""), self.usuario_id)
                self.comunidades_suscritas.add(comunidad_id)
                resultado = {"id": comunidad_id, "nombre": nombre}

            elif accion == "comunidad_listar":
                self._requerir_sesion()
                comunidades = db.comunidades_de(self.usuario_id)
                self.comunidades_suscritas.update(c["id"] for c in comunidades)
                resultado = {"comunidades": comunidades}

            elif accion == "comunidad_chat_enviar":
                self._requerir_sesion()
                comunidad_id = int(peticion.get("comunidad_id"))
                if not db.es_miembro(comunidad_id, self.usuario_id):
                    raise ErrorSiPiWorld("No sos miembro de esa comunidad.")
                texto = str(peticion.get("texto", "")).strip()[:2000]
                if not texto:
                    raise ErrorSiPiWorld("El mensaje esta vacio.")
                db.guardar_mensaje_comunidad(comunidad_id, self.usuario_id, texto)
                self.servidor.difundir_comunidad(comunidad_id, {
                    "tipo": "evento", "evento": "mensaje_comunidad", "comunidad_id": comunidad_id,
                    "autor": self.nombre_usuario, "texto": texto, "enviado_en": time.time(),
                }, excluir=self)
                resultado = {}

            elif accion == "comunidad_chat_historial":
                self._requerir_sesion()
                comunidad_id = int(peticion.get("comunidad_id"))
                if not db.es_miembro(comunidad_id, self.usuario_id):
                    raise ErrorSiPiWorld("No sos miembro de esa comunidad.")
                limite = min(int(peticion.get("limite", 50)), LIMITE_MAXIMO_HISTORIAL)
                resultado = {"mensajes": db.historial_comunidad(comunidad_id, limite)}

            elif accion == "proyecto_crear":
                self._requerir_sesion()
                comunidad_id = int(peticion.get("comunidad_id"))
                if not db.es_miembro(comunidad_id, self.usuario_id):
                    raise ErrorSiPiWorld("No sos miembro de esa comunidad.")
                proyecto_id = db.crear_proyecto(
                    comunidad_id, peticion.get("nombre", ""), peticion.get("descripcion", ""), self.usuario_id,
                    clave_acceso=peticion.get("clave_acceso", ""))
                self.servidor.difundir_comunidad(comunidad_id, {
                    "tipo": "evento", "evento": "proyecto_nuevo", "comunidad_id": comunidad_id,
                    "id": proyecto_id, "nombre": peticion.get("nombre", ""), "creador": self.nombre_usuario,
                }, excluir=self)
                resultado = {"id": proyecto_id}

            elif accion == "proyecto_obtener_codigo":
                self._requerir_sesion()
                comunidad_id = int(peticion.get("comunidad_id"))
                if not db.es_miembro(comunidad_id, self.usuario_id):
                    raise ErrorSiPiWorld("No sos miembro de esa comunidad.")
                resultado = db.obtener_codigo_proyecto(
                    int(peticion.get("proyecto_id")), comunidad_id, peticion.get("clave_acceso", ""))

            elif accion == "proyecto_guardar_codigo":
                self._requerir_sesion()
                comunidad_id = int(peticion.get("comunidad_id"))
                if not db.es_miembro(comunidad_id, self.usuario_id):
                    raise ErrorSiPiWorld("No sos miembro de esa comunidad.")
                proyecto_id = int(peticion.get("proyecto_id"))
                db.guardar_codigo_proyecto(
                    proyecto_id, comunidad_id, peticion.get("clave_acceso", ""),
                    str(peticion.get("codigo_sipi", "")))
                self.servidor.difundir_comunidad(comunidad_id, {
                    "tipo": "evento", "evento": "proyecto_codigo_actualizado", "comunidad_id": comunidad_id,
                    "proyecto_id": proyecto_id, "autor": self.nombre_usuario,
                }, excluir=self)
                resultado = {}

            elif accion == "proyecto_listar":
                self._requerir_sesion()
                comunidad_id = int(peticion.get("comunidad_id"))
                if not db.es_miembro(comunidad_id, self.usuario_id):
                    raise ErrorSiPiWorld("No sos miembro de esa comunidad.")
                resultado = {"proyectos": db.listar_proyectos(comunidad_id)}

            elif accion == "proyecto_actualizar_estado":
                self._requerir_sesion()
                comunidad_id = int(peticion.get("comunidad_id"))
                if not db.es_miembro(comunidad_id, self.usuario_id):
                    raise ErrorSiPiWorld("No sos miembro de esa comunidad.")
                db.actualizar_estado_proyecto(
                    int(peticion.get("proyecto_id")), comunidad_id, peticion.get("estado", ""))
                self.servidor.difundir_comunidad(comunidad_id, {
                    "tipo": "evento", "evento": "proyecto_actualizado", "comunidad_id": comunidad_id,
                    "proyecto_id": int(peticion.get("proyecto_id")), "estado": peticion.get("estado"),
                }, excluir=self)
                resultado = {}

            elif accion == "ping":
                resultado = {"pong": True, "version_protocolo": VERSION_PROTOCOLO}

            else:
                raise ErrorSiPiWorld(f"Accion desconocida: '{accion}'.")

            self._enviar({"tipo": "respuesta", "id_peticion": id_peticion, "ok": True, **resultado})

        except ErrorSiPiWorld as e:
            self._enviar({"tipo": "respuesta", "id_peticion": id_peticion, "ok": False, "error": str(e)})
        except Exception as e:  # nunca tumbar el hilo del cliente por un error inesperado
            self._enviar({"tipo": "respuesta", "id_peticion": id_peticion, "ok": False,
                           "error": f"Error interno del servidor: {e}"})


class ServidorSiPiWorld:
    def __init__(self, host="0.0.0.0", puerto=PUERTO_POR_DEFECTO, ruta_db="sipi_world.sqlite3",
                 max_conexiones=200):
        self.host = host
        self.puerto = puerto
        self.db = BaseDatos(ruta_db)
        self.clientes = set()
        self._lock_clientes = threading.Lock()
        self._socket = None
        # Techo de conexiones simultaneas -- pensado como red de seguridad,
        # no como limite real de uso (200 es muchisimo mas que lo que un
        # puñado de personas va a abrir a la vez): protege la PC de un
        # escenario raro (bug de reconexion en bucle, alguien probando el
        # servidor de forma abusiva) sin afectar en nada el uso normal.
        self.max_conexiones = max_conexiones

    def difundir_global(self, mensaje, excluir=None):
        for cliente in list(self.clientes):
            if cliente.usuario_id is not None and cliente is not excluir:
                cliente._enviar(mensaje)

    def difundir_comunidad(self, comunidad_id, mensaje, excluir=None):
        for cliente in list(self.clientes):
            if comunidad_id in cliente.comunidades_suscritas and cliente is not excluir:
                cliente._enviar(mensaje)

    def ejecutar(self):
        self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self._socket.bind((self.host, self.puerto))
        self._socket.listen(50)
        print(f"[sipi-world] escuchando en {self.host}:{self.puerto} (base de datos: {self.db.ruta})")
        print(f"[sipi-world] limite de conexiones simultaneas: {self.max_conexiones}")
        try:
            while True:
                conexion, direccion = self._socket.accept()
                if len(self.clientes) >= self.max_conexiones:
                    # Se rechaza prolijo (no se acepta el hilo) en vez de
                    # dejar crecer sin techo -- con 3 personas esto nunca se
                    # va a disparar en uso normal, es solo el freno de mano.
                    try:
                        conexion.sendall(
                            (json.dumps({"tipo": "respuesta", "ok": False,
                                         "error": "Servidor lleno, intenta de nuevo en un momento."},
                                        ensure_ascii=False) + "\n").encode("utf-8"))
                        conexion.close()
                    except OSError:
                        pass
                    continue
                ManejadorCliente(conexion, direccion, self).start()
        except KeyboardInterrupt:
            print("\n[sipi-world] cerrando servidor...")
        finally:
            self._socket.close()


def main():
    parser = argparse.ArgumentParser(description="Servidor de SiPi World (cuentas, chat, comunidades, proyectos).")
    parser.add_argument("--host", default="0.0.0.0", help="Direccion donde escuchar (0.0.0.0 = todas las interfaces)")
    parser.add_argument("--puerto", type=int, default=PUERTO_POR_DEFECTO)
    parser.add_argument("--db", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "sipi_world.sqlite3"))
    parser.add_argument("--max-conexiones", type=int, default=200,
                         help="Techo de conexiones simultaneas, como red de seguridad para la PC que hostea "
                              "(default: 200 -- de sobra para un grupo chico, ver docs para subirlo/bajarlo).")
    args = parser.parse_args()
    ServidorSiPiWorld(host=args.host, puerto=args.puerto, ruta_db=args.db,
                       max_conexiones=args.max_conexiones).ejecutar()


if __name__ == "__main__":
    main()
