#!/usr/bin/env python3
"""
sipi_world_cliente.py - Cliente de SiPi World (ver sipi_world_servidor.py
para el protocolo completo).

Pensado para usarse desde una interfaz grafica: todas las llamadas de
red pasan por un hilo aparte (SiPiWorldCliente._hilo_lectura), asi que
nunca bloquean la ventana. Los eventos que llegan sin haber sido
pedidos (mensaje nuevo de otro usuario, proyecto nuevo, etc.) se
acumulan en 'self.cola_eventos' -- quien use este cliente desde una
interfaz Tkinter los procesa periodicamente con 'root.after(...)' (ver
'VentanaSiPiWorld' en editor_sipi.py).
"""
import json
import queue
import socket
import threading
import uuid


class ErrorConexionSiPiWorld(Exception):
    pass


class ErrorSiPiWorld(Exception):
    """Error de negocio devuelto por el servidor (usuario invalido,
    comunidad inexistente, etc.) -- se distingue de un error de red."""


class SiPiWorldCliente:
    def __init__(self):
        self._socket = None
        self._archivo = None
        self._hilo = None
        self._lock_escritura = threading.Lock()
        self._pendientes = {}  # id_peticion -> queue.Queue de 1 elemento (la respuesta)
        self._lock_pendientes = threading.Lock()
        self.cola_eventos = queue.Queue()
        self.conectado = False
        self.usuario = None
        self.usuario_id = None

    # ---------------------------------------------------------- conexion
    def conectar(self, host, puerto, tiempo_espera=6):
        try:
            self._socket = socket.create_connection((host, puerto), timeout=tiempo_espera)
        except OSError as e:
            raise ErrorConexionSiPiWorld(
                f"No se pudo conectar a {host}:{puerto} -- {e}. "
                f"Verificá que el servidor de SiPi World esté corriendo ahí y que el puerto esté abierto."
            )
        # Keepalive TCP: si el servidor (o la red) se cae sin avisar, el
        # sistema operativo se da cuenta y la lectura corta en vez de
        # quedar esperando para siempre -- asi el cliente detecta la
        # desconexion (evento 'desconectado') incluso ante un corte silencioso.
        try:
            self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
            if hasattr(socket, "TCP_KEEPIDLE"):
                self._socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
            if hasattr(socket, "TCP_KEEPINTVL"):
                self._socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 15)
            if hasattr(socket, "TCP_KEEPCNT"):
                self._socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4)
        except OSError:
            pass
        self._socket.settimeout(None)
        self._archivo = self._socket.makefile("rwb")
        self.conectado = True
        self._hilo = threading.Thread(target=self._hilo_lectura, daemon=True)
        self._hilo.start()

    def desconectar(self):
        self.conectado = False
        try:
            if self._socket is not None:
                self._socket.close()
        except OSError:
            pass

    def _hilo_lectura(self):
        try:
            while self.conectado:
                linea = self._archivo.readline()
                if not linea:
                    break
                try:
                    mensaje = json.loads(linea.decode("utf-8"))
                except (json.JSONDecodeError, UnicodeDecodeError):
                    continue
                if mensaje.get("tipo") == "respuesta":
                    id_peticion = mensaje.get("id_peticion")
                    with self._lock_pendientes:
                        cola = self._pendientes.pop(id_peticion, None)
                    if cola is not None:
                        cola.put(mensaje)
                else:
                    # evento no solicitado (mensaje de otro usuario,
                    # proyecto nuevo, etc.) -- la interfaz lo consume aparte
                    self.cola_eventos.put(mensaje)
        except OSError:
            pass
        finally:
            self.conectado = False
            self.cola_eventos.put({"tipo": "evento", "evento": "desconectado"})

    # ---------------------------------------------------------- peticiones
    def _pedir(self, accion, tiempo_espera=8, **kwargs):
        if not self.conectado:
            raise ErrorConexionSiPiWorld("No hay conexión activa con el servidor de SiPi World.")
        id_peticion = uuid.uuid4().hex
        cola_respuesta = queue.Queue(maxsize=1)
        with self._lock_pendientes:
            self._pendientes[id_peticion] = cola_respuesta
        mensaje = {"accion": accion, "id_peticion": id_peticion, **kwargs}
        try:
            with self._lock_escritura:
                self._archivo.write((json.dumps(mensaje, ensure_ascii=False) + "\n").encode("utf-8"))
                self._archivo.flush()
        except OSError as e:
            with self._lock_pendientes:
                self._pendientes.pop(id_peticion, None)
            raise ErrorConexionSiPiWorld(f"Se perdió la conexión con el servidor: {e}")

        try:
            respuesta = cola_respuesta.get(timeout=tiempo_espera)
        except queue.Empty:
            with self._lock_pendientes:
                self._pendientes.pop(id_peticion, None)
            raise ErrorConexionSiPiWorld("El servidor no respondió a tiempo.")

        if not respuesta.get("ok"):
            raise ErrorSiPiWorld(respuesta.get("error", "Error desconocido del servidor."))
        return respuesta

    # ---------------------------------------------------------- acciones
    def registrar(self, usuario, clave):
        r = self._pedir("registrar", usuario=usuario, clave=clave)
        self.usuario, self.usuario_id = r["usuario"], r["usuario_id"]
        return r

    def iniciar_sesion(self, usuario, clave):
        r = self._pedir("iniciar_sesion", usuario=usuario, clave=clave)
        self.usuario, self.usuario_id = r["usuario"], r["usuario_id"]
        return r

    def chat_global_enviar(self, texto):
        return self._pedir("chat_global_enviar", texto=texto)

    def chat_global_historial(self, limite=50):
        return self._pedir("chat_global_historial", limite=limite)["mensajes"]

    def comunidad_crear(self, nombre, descripcion=""):
        return self._pedir("comunidad_crear", nombre=nombre, descripcion=descripcion)

    def comunidad_unirse(self, codigo_invitacion):
        return self._pedir("comunidad_unirse", codigo_invitacion=codigo_invitacion)

    def comunidad_listar(self):
        return self._pedir("comunidad_listar")["comunidades"]

    def comunidad_chat_enviar(self, comunidad_id, texto):
        return self._pedir("comunidad_chat_enviar", comunidad_id=comunidad_id, texto=texto)

    def comunidad_chat_historial(self, comunidad_id, limite=50):
        return self._pedir("comunidad_chat_historial", comunidad_id=comunidad_id, limite=limite)["mensajes"]

    def proyecto_crear(self, comunidad_id, nombre, descripcion="", clave_acceso=""):
        return self._pedir("proyecto_crear", comunidad_id=comunidad_id, nombre=nombre,
                            descripcion=descripcion, clave_acceso=clave_acceso)

    def proyecto_listar(self, comunidad_id):
        return self._pedir("proyecto_listar", comunidad_id=comunidad_id)["proyectos"]

    def proyecto_actualizar_estado(self, comunidad_id, proyecto_id, estado):
        return self._pedir("proyecto_actualizar_estado", comunidad_id=comunidad_id,
                            proyecto_id=proyecto_id, estado=estado)

    def proyecto_obtener_codigo(self, comunidad_id, proyecto_id, clave_acceso=""):
        """Trae el codigo SiPi guardado en el proyecto. Si el proyecto tiene
        contraseña, hay que pasarla igual (el servidor la valida); si no
        tiene, cualquier miembro de la comunidad puede pedirlo sin clave."""
        r = self._pedir("proyecto_obtener_codigo", comunidad_id=comunidad_id,
                         proyecto_id=proyecto_id, clave_acceso=clave_acceso)
        return {"codigo_sipi": r["codigo_sipi"], "nombre": r["nombre"],
                "actualizado_en": r.get("actualizado_en"), "tiene_clave": r.get("tiene_clave", False)}

    def proyecto_guardar_codigo(self, comunidad_id, proyecto_id, codigo_sipi, clave_acceso=""):
        """Sobreescribe el codigo SiPi del proyecto (mismo ID). Requiere la
        misma contraseña con la que se creo, si tiene una."""
        return self._pedir("proyecto_guardar_codigo", comunidad_id=comunidad_id, proyecto_id=proyecto_id,
                            codigo_sipi=codigo_sipi, clave_acceso=clave_acceso)
