#!/usr/bin/env python3
"""
ZeroChat - Backend Local Unificado y Gestor de Entorno

Proporciona:
1. Auto-creación del entorno MCP local `~/zerochat/.venv`.
2. Servidor local HTTP y Server-Sent Events (SSE) con autenticación estricta por token efímero.
3. Herramientas locales seguras: read_file, edit_file, list_directory, execute_command.
4. Apertura automática del navegador apuntando a zerochat.html con token en el fragmento hash.
"""

from __future__ import annotations

import argparse
import ast
import atexit
import datetime
import fnmatch
import hmac
import importlib.metadata
import json
import os
import platform
import queue
import re
import secrets
import shutil
import shlex
import signal
import subprocess
import sys
import tempfile
import threading
import time
import traceback
import urllib.request
from urllib.parse import urlencode
import venv
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

SOURCE_BACKEND_VERSION = "7.11.0"

def _read_source_version(filename: str) -> str | None:
    """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
    try:
        version_file = Path(__file__).resolve().parent / filename
        if version_file.is_file():
            content = version_file.read_text(encoding="utf-8")
            match = re.search(r'version\s*=\s*"([0-9]+\.[0-9]+\.[0-9]+)"', content)
            if match:
                return match.group(1)
    except Exception:
        pass
    return None


def _read_backend_version() -> str:
    """Devuelve la versión publicada del backend, sin depender de package.json instalado."""
    source_version = _read_source_version("pyproject.toml")
    if source_version:
        return source_version
    try:
        return importlib.metadata.version("zerochat")
    except importlib.metadata.PackageNotFoundError:
        return SOURCE_BACKEND_VERSION


def _read_ui_version() -> str:
    """Devuelve la versión de la interfaz cuando se ejecuta desde el repositorio."""
    try:
        pkg_path = Path(__file__).resolve().parent / "package.json"
        if pkg_path.is_file():
            data = json.loads(pkg_path.read_text(encoding="utf-8"))
            if isinstance(data.get("version"), str):
                return data["version"].strip()
    except Exception:
        pass
    return BACKEND_PACKAGE_VERSION


def compatibility_version(version: str) -> str:
    """La interfaz y el backend son compatibles si comparten major.minor."""
    parts = version.split(".")
    return ".".join(parts[:2]) if len(parts) >= 2 else version

BACKEND_PACKAGE_VERSION = _read_backend_version()
VERSION = compatibility_version(BACKEND_PACKAGE_VERSION)
UI_VERSION = _read_ui_version()
DEFAULT_PORT = 6388
DEFAULT_HOST = "127.0.0.1"
DEFAULT_UI_URL = "https://albalday.github.io/zerochat/zerochat.html"
REMOTE_VERSION_URL = "https://raw.githubusercontent.com/albalday/zerochat/master/package.json"
PYPI_VERSION_URL = "https://pypi.org/pypi/zerochat/json"
REMOTE_SCRIPT_URL = "https://raw.githubusercontent.com/albalday/zerochat/master/zerochat.py"
CONSOLE_STATUS_IDLE_SECONDS = 8.0
CONSOLE_CONTROL = None


def format_uptime(seconds: float) -> str:
    """Devuelve una duración breve y estable para la línea de estado de consola."""
    total = max(0, int(seconds))
    hours, remainder = divmod(total, 3600)
    minutes, secs = divmod(remainder, 60)
    return f"{hours:02d}:{minutes:02d}:{secs:02d}"


class ConsoleControl:
    """Atajos de consola y línea de estado, solo para terminales interactivos."""
    def __init__(self, server: ThreadingHTTPServer, parser: argparse.ArgumentParser, target_url: str | None = None):
        self.server = server
        self.parser = parser
        self.target_url = target_url
        self.started_at = time.monotonic()
        self.last_activity = self.started_at
        self.stop_event = threading.Event()
        self.lock = threading.Lock()
        self.status_visible = False
        self.enabled = bool(getattr(sys.stdin, "isatty", lambda: False)() and getattr(sys.stdout, "isatty", lambda: False)())
        self._threads: list[threading.Thread] = []
        self._terminal_fd: int | None = None
        self._terminal_state = None
        self._closed = False

    def start(self):
        if not self.enabled:
            return
        if os.name != "nt":
            try:
                import termios
                import tty
                self._terminal_fd = sys.stdin.fileno()
                self._terminal_state = termios.tcgetattr(self._terminal_fd)
                tty.setcbreak(self._terminal_fd)
            except (OSError, ValueError):
                self._restore_terminal()
                self.enabled = False
                return
        self._threads = [
            threading.Thread(target=self._status_loop, name="zerochat-console-status", daemon=True),
            threading.Thread(target=self._keyboard_loop, name="zerochat-console-input", daemon=True),
        ]
        for thread in self._threads:
            thread.start()

    def close(self):
        if self._closed:
            return
        self._closed = True
        self.stop_event.set()
        for thread in self._threads:
            if thread is not threading.current_thread():
                thread.join(timeout=1.0)
        self._restore_terminal()
        self.clear_status(final=True)

    def _restore_terminal(self):
        if self._terminal_fd is None or self._terminal_state is None:
            return
        try:
            import termios
            termios.tcsetattr(self._terminal_fd, termios.TCSADRAIN, self._terminal_state)
        except OSError:
            pass
        finally:
            self._terminal_fd = None
            self._terminal_state = None

    def clear_status(self, *, final: bool = False):
        if not self.enabled:
            return
        with self.lock:
            if self.status_visible:
                sys.stdout.write("\r\033[2K")
                self.status_visible = False
            if final:
                sys.stdout.write("\r\n")
            sys.stdout.flush()

    def log(self, message: str, *, flush: bool = True):
        with self.lock:
            if self.enabled and self.status_visible:
                sys.stdout.write("\r\033[2K")
                self.status_visible = False
            print(message, flush=flush)
            self.last_activity = time.monotonic()

    def show_help(self):
        with self.lock:
            if self.status_visible:
                sys.stdout.write("\r\033[2K")
                self.status_visible = False
            print("\nComandos de consola: [h] ayuda · [n] Navegador · [x] salir ordenadamente\n", flush=True)
            print(self.parser.format_help().rstrip(), flush=True)
            self.last_activity = time.monotonic()

    def _render_status(self):
        if not self.enabled:
            return
        uptime = format_uptime(time.monotonic() - self.started_at)
        with self.lock:
            if time.monotonic() - self.last_activity < CONSOLE_STATUS_IDLE_SECONDS:
                return
            sys.stdout.write(f"\r\033[2KZeroChat activo {uptime} · [h] ayuda · [n] Navegador · [x] salir")
            sys.stdout.flush()
            self.status_visible = True

    def _status_loop(self):
        while not self.stop_event.wait(1.0):
            self._render_status()

    def _handle_key(self, key: str):
        if key.lower() == "h":
            self.show_help()
        elif key.lower() == "n":
            if self.target_url:
                launch_browser(self.target_url)
        elif key.lower() == "x":
            self.log(f"[{time.strftime('%H:%M:%S')}] Deteniendo servidor ZeroChat...")
            stop_zerochat_server(self.server)

    def _keyboard_loop(self):
        if os.name == "nt":
            import msvcrt
            while not self.stop_event.wait(0.05):
                if msvcrt.kbhit():
                    self._handle_key(msvcrt.getwch())
            return

        import select
        while not self.stop_event.is_set():
            ready, _, _ = select.select([sys.stdin], [], [], 0.1)
            if ready:
                self._handle_key(sys.stdin.read(1))


def console_log(message: str, *, flush: bool = True):
    if CONSOLE_CONTROL:
        CONSOLE_CONTROL.log(message, flush=flush)
    else:
        print(message, flush=flush)

def get_dev_root() -> Path | None:
    """
    Detecta si zerochat.py se está ejecutando en el directorio de desarrollo del repositorio.
    Comprueba si existen zerochat.html, js/ y css/ en el directorio del script o en cwd.
    """
    script_dir = Path(__file__).resolve().parent
    cwd = Path.cwd().resolve()
    for candidate in (script_dir, cwd):
        if (candidate / "zerochat.html").is_file() and (candidate / "js").is_dir() and (candidate / "css").is_dir():
            return candidate
    return None


def get_static_root() -> Path | None:
    """La interfaz local solo se sirve al ejecutar el repositorio de desarrollo."""
    return get_dev_root()


def is_installed_runtime() -> bool:
    """Identifica el ejecutable instalado desde PyPI, sin confundirlo con el repositorio."""
    if get_dev_root() is not None:
        return False
    try:
        return importlib.metadata.version("zerochat") == BACKEND_PACKAGE_VERSION
    except importlib.metadata.PackageNotFoundError:
        return False


def get_data_dir() -> Path:
    """Devuelve el directorio local que concentra el estado y los MCP de ZeroChat."""
    configured = os.environ.get("ZEROCHAT_DATA_DIR", "").strip()
    if configured:
        return Path(configured).expanduser().resolve()
    return (Path.home() / "zerochat").resolve()


def get_venv_dir() -> Path:
    """Devuelve el entorno aislado usado exclusivamente por los MCP Python."""
    return get_data_dir() / ".venv"


def get_daily_token() -> str:
    """Devuelve un token de sesión diario persistido en ~/zerochat/config/token.json."""
    config_dir = get_data_dir() / "config"
    config_dir.mkdir(parents=True, exist_ok=True)
    token_file = config_dir / "token.json"
    today = datetime.date.today().isoformat()
    if token_file.exists():
        try:
            data = json.loads(token_file.read_text(encoding="utf-8"))
            if data.get("date") == today and data.get("token") and isinstance(data["token"], str):
                return data["token"]
        except Exception:
            pass
    token = secrets.token_urlsafe(32)
    try:
        tmp_file = token_file.with_suffix(".tmp")
        tmp_file.write_text(json.dumps({"token": token, "date": today}, indent=2), encoding="utf-8")
        tmp_file.replace(token_file)
    except Exception:
        pass
    return token


# Estado de sesión en memoria (generación diaria por defecto)
SESSION_TOKEN = get_daily_token()
ACTIVE_PORT = DEFAULT_PORT
ACTIVE_HOST = DEFAULT_HOST

DETECTED_OS = "windows" if sys.platform.startswith("win") else ("android" if "ANDROID_ROOT" in os.environ else "linux")


def get_venv_python(venv_dir: Path) -> Path:
    """Devuelve el ejecutable de Python del entorno virtual según el SO."""
    if sys.platform.startswith("win"):
        return venv_dir / "Scripts" / "python.exe"
    return venv_dir / "bin" / "python"


def ensure_virtual_environment():
    """
    Crea ~/zerochat/.venv para dependencias MCP en ambos modos de distribución.
    El servidor conserva el intérprete con el que fue iniciado; el entorno se usa
    exclusivamente al lanzar procesos MCP Python.
    """
    venv_dir = get_venv_dir()
    venv_py = get_venv_python(venv_dir)

    # 1. Crear el venv si no existe
    if not venv_py.exists():
        console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Inicializando entorno virtual en {venv_dir}...", flush=True)
        try:
            venv.create(venv_dir, with_pip=True, clear=False)
            console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Entorno virtual preparado con éxito.", flush=True)
        except Exception as err:
            console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Advertencia al crear venv: {err}. Continuando con intérprete actual.", flush=True)
            return

def parse_version(ver: str) -> tuple[int, ...]:
    """Convierte una cadena de versión semántica en tupla de enteros para comparación."""
    parts = []
    for piece in ver.split("."):
        clean = "".join(filter(str.isdigit, piece))
        if clean:
            parts.append(int(clean))
    return tuple(parts)


def _read_remote_version(url: str) -> str | None:
    """Obtiene una versión publicada desde package.json o la API JSON de PyPI."""
    req = urllib.request.Request(url, headers={"User-Agent": f"ZeroChat/{VERSION}"})
    with urllib.request.urlopen(req, timeout=3) as resp:
        content = resp.read(4096).decode("utf-8", errors="ignore")
    try:
        data = json.loads(content)
        if isinstance(data, dict):
            candidate = data.get("version")
            if not isinstance(candidate, str) and isinstance(data.get("info"), dict):
                candidate = data["info"].get("version")
            if isinstance(candidate, str):
                return candidate.strip()
    except (json.JSONDecodeError, TypeError):
        pass
    match = re.search(r'["\']?version["\']?\s*[:=]\s*["\'](\d+\.\d+\.\d+)["\']', content)
    return match.group(1) if match else None


def has_new_backend_version(remote_version: str, local_version: str = VERSION) -> bool:
    """Compara solo major.minor: los parches pertenecen a la interfaz web."""
    return parse_version(compatibility_version(remote_version)) > parse_version(compatibility_version(local_version))


def check_version():
    """Informa de actualizaciones del backend, sin avisar por parches web."""
    if get_dev_root() is not None:
        return
    try:
        installed = is_installed_runtime()
        remote_ver = _read_remote_version(PYPI_VERSION_URL if installed else REMOTE_VERSION_URL)
        if remote_ver and re.match(r"^\d+(\.\d+)+", remote_ver) and has_new_backend_version(remote_ver):
            console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Nueva versión del servidor disponible (Local: {VERSION}, Remota: {compatibility_version(remote_ver)})", flush=True)
            if installed:
                console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Actualiza cuando quieras con: {sys.executable} -m pip install --upgrade --no-cache-dir zerochat", flush=True)
            else:
                console_log(f"[{time.strftime('%H:%M:%S')}] [zerochat] Actualiza con: curl -sSL {REMOTE_SCRIPT_URL} -o zerochat.py", flush=True)
    except Exception:
        # Modo offline o timeout ignorado de forma segura
        pass


# ==============================================================================
# Herramientas Locales Core
# ==============================================================================

def list_directory(path: str = ".", recursive: bool = False) -> str:
    """List files and directories in a local directory with safe bounded recursive traversal."""
    try:
        target = Path(path).expanduser().resolve()
        if not target.exists():
            return json.dumps({"success": False, "error": f"Path '{path}' does not exist."}, ensure_ascii=False)
        if not target.is_dir():
            return json.dumps({"success": False, "error": f"Path '{path}' is not a directory."}, ensure_ascii=False)

        max_depth = 3 if recursive else 1
        entries = []

        def _scan(current_path: Path, current_depth: int):
            if current_depth > max_depth or len(entries) >= 1000:
                return
            try:
                with os.scandir(current_path) as it:
                    items = list(it)
                    items.sort(key=lambda e: (not e.is_dir(follow_symlinks=False), e.name.lower()))
                    for entry in items:
                        if len(entries) >= 1000:
                            break
                        try:
                            stat = entry.stat(follow_symlinks=False)
                            is_dir = entry.is_dir(follow_symlinks=False)
                            entry_data = {
                                "name": entry.name,
                                "path": str(Path(entry.path).resolve()),
                                "type": "directory" if is_dir else "file",
                                "size_bytes": None if is_dir else stat.st_size,
                                "is_symlink": entry.is_symlink()
                            }
                            if recursive:
                                entry_data["relative_path"] = str(Path(entry.path).resolve().relative_to(target))
                            entries.append(entry_data)
                            if recursive and is_dir and not entry.is_symlink():
                                _scan(Path(entry.path), current_depth + 1)
                        except (PermissionError, FileNotFoundError):
                            continue
            except (PermissionError, FileNotFoundError):
                pass

        _scan(target, 1)

        return json.dumps({
            "success": True,
            "path": str(target),
            "recursive": bool(recursive),
            "total_items": len(entries),
            "entries": entries
        }, ensure_ascii=False, indent=2)
    except Exception as e:
        return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)


def read_file(path: str, start_line: int = 1, end_line: int = None, max_lines: int = 500, max_bytes: int = 100000) -> str:
    """Read text content from a local file in streaming mode with safe ranges and bounds."""
    try:
        target = Path(path).expanduser().resolve()
        if not target.exists():
            return json.dumps({"success": False, "error": f"File '{path}' does not exist."}, ensure_ascii=False)
        if not target.is_file():
            return json.dumps({"success": False, "error": f"Path '{path}' is not a regular file."}, ensure_ascii=False)

        file_size = target.stat().st_size
        safe_max_bytes = max(1024, min(int(max_bytes), 2000000))
        safe_start_line = max(1, int(start_line))

        if end_line is not None:
            safe_end_line = max(safe_start_line, int(end_line))
            target_line_count = safe_end_line - safe_start_line + 1
        else:
            safe_end_line = None
            target_line_count = max(1, min(int(max_lines), 2000))

        selected_lines = []
        current_bytes = 0
        truncated_bytes = False
        total_lines = 0
        reached_end_bound = False

        with open(target, "r", encoding="utf-8", errors="replace") as f:
            for line_no, line in enumerate(f, 1):
                total_lines = line_no
                if line_no < safe_start_line:
                    continue
                if safe_end_line is not None and line_no > safe_end_line:
                    reached_end_bound = True
                    if line_no > safe_start_line + 50000:
                        break
                    continue
                if safe_end_line is None and len(selected_lines) >= target_line_count:
                    reached_end_bound = True
                    if line_no > safe_start_line + 50000:
                        break
                    continue

                if not reached_end_bound:
                    line_bytes = len(line.encode("utf-8"))
                    if current_bytes + line_bytes > safe_max_bytes:
                        remaining_budget = max(0, safe_max_bytes - current_bytes)
                        if remaining_budget > 0:
                            encoded = line.encode("utf-8")[:remaining_budget]
                            selected_lines.append(encoded.decode("utf-8", errors="ignore"))
                        truncated_bytes = True
                        reached_end_bound = True
                    else:
                        selected_lines.append(line)
                        current_bytes += line_bytes

        content = "".join(selected_lines)
        last_returned_line = safe_start_line + len(selected_lines) - 1 if selected_lines else safe_start_line - 1

        is_truncated = truncated_bytes or (safe_end_line is not None and safe_end_line < total_lines) or (safe_end_line is None and (safe_start_line + target_line_count - 1) < total_lines)

        return json.dumps({
            "success": True,
            "path": str(target),
            "size_bytes": file_size,
            "total_lines": total_lines,
            "start_line": safe_start_line,
            "end_line": last_returned_line,
            "lines_returned": len(selected_lines),
            "truncated": is_truncated,
            "content": content
        }, ensure_ascii=False, indent=2)
    except Exception as e:
        return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)


def write_file(path: str, content: str) -> str:
    """Create or overwrite a file completely and atomically."""
    try:
        target = Path(path).expanduser().resolve()
        target.parent.mkdir(parents=True, exist_ok=True)
        temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
        with open(temp_path, "w", encoding="utf-8") as f:
            f.write(content)
        temp_path.replace(target)
        bytes_written = len(content.encode("utf-8"))
        return json.dumps({
            "success": True,
            "path": str(target),
            "bytes_written": bytes_written
        }, ensure_ascii=False, indent=2)
    except Exception as e:
        return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)


def edit_file(path: str, old_str: str = None, new_str: str = None, content: str = None, mode: str = "surgical", target_content: str = None) -> str:
    """Modify an existing file surgically or atomically."""
    try:
        target = Path(path).expanduser().resolve()

        # Preferred surgical mode (old_str -> new_str)
        if old_str is not None and new_str is not None:
            if not target.exists():
                return json.dumps({"success": False, "error": f"File '{path}' does not exist. Use read_file to verify existing paths."}, ensure_ascii=False)
            if not target.is_file():
                return json.dumps({"success": False, "error": f"Path '{path}' is not a regular file."}, ensure_ascii=False)

            with open(target, "r", encoding="utf-8", errors="replace") as f:
                file_text = f.read()

            occurrences = file_text.count(old_str)
            if occurrences == 0:
                return json.dumps({
                    "success": False,
                    "error": f"Target text was not found in '{path}'. Check exact file content with read_file."
                }, ensure_ascii=False)
            if occurrences > 1:
                return json.dumps({
                    "success": False,
                    "error": f"Found {occurrences} matches for the snippet in '{path}'. Provide more surrounding context in old_str to ensure a unique match."
                }, ensure_ascii=False)

            updated_text = file_text.replace(old_str, new_str, 1)
            temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
            with open(temp_path, "w", encoding="utf-8") as f:
                f.write(updated_text)
            temp_path.replace(target)
            return json.dumps({
                "success": True,
                "path": str(target),
                "mode": "surgical",
                "bytes_written": len(updated_text.encode("utf-8")),
                "replacements": 1
            }, ensure_ascii=False, indent=2)

        # Legacy modes (write, append, replace_chunk)
        if content is None:
            return json.dumps({"success": False, "error": "Must provide old_str and new_str for surgical edit, or content for compatible modes."}, ensure_ascii=False)

        target.parent.mkdir(parents=True, exist_ok=True)
        if mode == "append":
            with open(target, "a", encoding="utf-8") as f:
                f.write(content)
            bytes_written = len(content.encode("utf-8"))
        elif mode == "replace_chunk":
            if not target.exists():
                return json.dumps({"success": False, "error": f"File '{path}' does not exist for replace_chunk."}, ensure_ascii=False)
            if not target_content:
                return json.dumps({"success": False, "error": "target_content is required in replace_chunk mode."}, ensure_ascii=False)

            with open(target, "r", encoding="utf-8", errors="replace") as f:
                existing = f.read()

            if target_content not in existing:
                return json.dumps({"success": False, "error": "target_content was not found in the file."}, ensure_ascii=False)

            new_text = existing.replace(target_content, content, 1)
            temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
            with open(temp_path, "w", encoding="utf-8") as f:
                f.write(new_text)
            temp_path.replace(target)
            bytes_written = len(new_text.encode("utf-8"))
        else:  # write
            temp_path = target.with_suffix(target.suffix + f".tmp_{os.getpid()}_{time.time_ns()}")
            with open(temp_path, "w", encoding="utf-8") as f:
                f.write(content)
            temp_path.replace(target)
            bytes_written = len(content.encode("utf-8"))

        return json.dumps({
            "success": True,
            "path": str(target),
            "mode": mode,
            "bytes_written": bytes_written
        }, ensure_ascii=False, indent=2)
    except Exception as e:
        return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)


def truncate_terminal_output(text: str, max_chars: int = 8000, head_lines: int = 50, tail_lines: int = 30) -> tuple[str, bool]:
    """Outputs >8,000 characters are truncated to first 50 lines + warning + last 30 lines."""
    if len(text) <= max_chars:
        return text, False
    lines = text.splitlines(keepends=True)
    if len(lines) <= head_lines + tail_lines:
        half = max_chars // 2
        return (
            text[:half]
            + f"\n\n[... Output truncated due to length ({len(text)} total characters) ...]\n\n"
            + text[-half:],
            True
        )
    omitted = len(lines) - head_lines - tail_lines
    head = "".join(lines[:head_lines])
    tail = "".join(lines[-tail_lines:])
    warning = f"\n\n[... Output truncated: omitted {omitted} lines ({len(text)} total characters) ...]\n\n"
    return head + warning + tail, True


class PersistentBashSession:
    """Maintains working directory (cwd) and environment variables across successive calls."""
    def __init__(self):
        self._dir = tempfile.mkdtemp(prefix="zerochat_bash_")
        self._cwd_file = Path(self._dir) / "cwd"
        self._env_file = Path(self._dir) / "env.sh"
        self._cwd = str(Path.cwd().resolve())
        self._lock = threading.Lock()
        atexit.register(self.cleanup)

    def cleanup(self):
        try:
            shutil.rmtree(self._dir, ignore_errors=True)
        except Exception:
            pass

    def run(self, command: str, timeout_seconds: int = 30) -> str:
        with self._lock:
            safe_timeout = max(1, min(int(timeout_seconds), 300))
            runner_script = Path(self._dir) / f"runner_{time.time_ns()}.sh"

            script_content = (
                "if [ -f " + shlex.quote(str(self._env_file)) + " ]; then\n"
                "  . " + shlex.quote(str(self._env_file)) + " 2>/dev/null || true\n"
                "fi\n"
                "cd " + shlex.quote(self._cwd) + " 2>/dev/null || true\n"
                "trap '__ret=$?; pwd > " + shlex.quote(str(self._cwd_file)) + "; export -p > " + shlex.quote(str(self._env_file)) + " 2>/dev/null || true; exit $__ret' EXIT\n"
                + command + "\n"
            )

            try:
                with open(runner_script, "w", encoding="utf-8") as f:
                    f.write(script_content)
                runner_script.chmod(0o700)

                proc = subprocess.Popen(
                    ["bash", str(runner_script)],
                    cwd=self._cwd,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    text=True,
                    encoding="utf-8",
                    errors="replace",
                    start_new_session=True if hasattr(os, "setsid") else False
                )

                try:
                    stdout, stderr = proc.communicate(timeout=safe_timeout)
                    returncode = proc.returncode
                except subprocess.TimeoutExpired:
                    try:
                        if hasattr(os, "killpg") and hasattr(os, "getpgid"):
                            os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                        else:
                            proc.kill()
                    except Exception:
                        try:
                            proc.kill()
                        except Exception:
                            pass
                    proc.communicate()
                    return json.dumps({
                        "success": False,
                        "error": f"Command timed out after {timeout_seconds} seconds (terminated with SIGKILL).",
                        "cwd": self._cwd
                    }, ensure_ascii=False)

                if self._cwd_file.is_file():
                    try:
                        saved_cwd = self._cwd_file.read_text(encoding="utf-8").strip()
                        if saved_cwd and Path(saved_cwd).is_dir():
                            self._cwd = saved_cwd
                    except Exception:
                        pass

                truncated_out, was_out_trunc = truncate_terminal_output(stdout)
                truncated_err, was_err_trunc = truncate_terminal_output(stderr)

                return json.dumps({
                    "success": returncode == 0,
                    "returncode": returncode,
                    "stdout": truncated_out,
                    "stderr": truncated_err,
                    "cwd": self._cwd,
                    "truncated": was_out_trunc or was_err_trunc
                }, ensure_ascii=False, indent=2)

            except Exception as e:
                return json.dumps({"success": False, "error": str(e), "cwd": self._cwd}, ensure_ascii=False)
            finally:
                try:
                    if runner_script.is_file():
                        runner_script.unlink()
                except Exception:
                    pass


BASH_SESSION = PersistentBashSession()


def bash(command: str, timeout_seconds: int = 30) -> str:
    """Execute a command in an interactive persistent bash shell session."""
    return BASH_SESSION.run(command, timeout_seconds=timeout_seconds)


def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) -> str:
    """Execute a command in the system shell and capture stdout and stderr."""
    if cwd == "." or cwd == BASH_SESSION._cwd:
        return BASH_SESSION.run(command, timeout_seconds=timeout_seconds)
    try:
        target_cwd = Path(cwd).expanduser().resolve()
        if not target_cwd.exists() or not target_cwd.is_dir():
            target_cwd = Path.cwd()

        proc = subprocess.run(
            command,
            cwd=str(target_cwd),
            shell=True,
            capture_output=True,
            text=True,
            timeout=max(1, min(int(timeout_seconds), 300)),
            encoding="utf-8",
            errors="replace"
        )
        trunc_out, was_out_trunc = truncate_terminal_output(proc.stdout)
        trunc_err, was_err_trunc = truncate_terminal_output(proc.stderr)
        return json.dumps({
            "success": proc.returncode == 0,
            "returncode": proc.returncode,
            "stdout": trunc_out,
            "stderr": trunc_err,
            "cwd": str(target_cwd),
            "truncated": was_out_trunc or was_err_trunc
        }, ensure_ascii=False, indent=2)
    except subprocess.TimeoutExpired:
        return json.dumps({"success": False, "error": f"Command timed out after {timeout_seconds} seconds."}, ensure_ascii=False)
    except Exception as e:
        return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)


def search_files(query: str, path: str = ".", file_pattern: str = None, max_results: int = 100) -> str:
    """Recursively search for plain text or regular expressions across project files."""
    try:
        target = Path(path).expanduser().resolve()
        if not target.exists():
            return json.dumps({"success": False, "error": f"Path '{path}' does not exist."}, ensure_ascii=False)
        if not target.is_dir():
            return json.dumps({"success": False, "error": f"Path '{path}' is not a directory."}, ensure_ascii=False)

        try:
            regex = re.compile(query, re.MULTILINE)
        except re.error:
            regex = re.compile(re.escape(query), re.MULTILINE)

        ignored_dirs = {".git", ".venv", "node_modules", "__pycache__", ".pytest_cache", ".cache"}
        matches = []
        files_searched = 0
        max_file_size = 2 * 1024 * 1024
        truncated = False

        for root, dirs, files in os.walk(target):
            dirs[:] = [d for d in dirs if d not in ignored_dirs and not d.startswith(".")]

            for fname in sorted(files):
                if file_pattern and not fnmatch.fnmatch(fname, file_pattern):
                    continue

                fpath = Path(root) / fname
                try:
                    stat = fpath.stat()
                    if stat.st_size > max_file_size:
                        continue
                except OSError:
                    continue

                files_searched += 1
                try:
                    with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
                        for line_idx, line in enumerate(f, 1):
                            if regex.search(line):
                                try:
                                    rel_path = str(fpath.relative_to(target))
                                except ValueError:
                                    rel_path = str(fpath)
                                matches.append({
                                    "file": str(fpath),
                                    "relative_path": rel_path,
                                    "line_number": line_idx,
                                    "content": line.rstrip("\r\n")[:300]
                                })
                                if len(matches) >= max_results:
                                    truncated = True
                                    break
                except (PermissionError, OSError):
                    continue

                if truncated:
                    break
            if truncated:
                break

        return json.dumps({
            "success": True,
            "path": str(target),
            "query": query,
            "file_pattern": file_pattern,
            "files_searched": files_searched,
            "total_matches": len(matches),
            "truncated": truncated,
            "matches": matches
        }, ensure_ascii=False, indent=2)
    except Exception as e:
        return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)


def get_diagnostics(path: str | None = None) -> str:
    """Retrieve syntax diagnostics and code errors for a file or the entire workspace."""
    try:
        if path is not None and str(path).strip():
            target = Path(str(path).strip()).expanduser().resolve()
        else:
            target = Path.cwd().resolve()

        if not target.exists():
            return json.dumps({
                "success": False,
                "error": f"Path '{path}' does not exist."
            }, ensure_ascii=False)

        diagnostics: list[dict] = []
        files_checked = 0

        def check_single_file(fpath: Path) -> list[dict]:
            ext = fpath.suffix.lower()
            file_diags: list[dict] = []
            try:
                rel_display = str(fpath.relative_to(Path.cwd()))
            except ValueError:
                rel_display = str(fpath)

            if ext == ".py":
                try:
                    content = fpath.read_text(encoding="utf-8", errors="replace")
                    compile(content, str(fpath), "exec")
                except (SyntaxError, IndentationError) as e:
                    file_diags.append({
                        "file": rel_display,
                        "line": e.lineno or 1,
                        "column": e.offset or 1,
                        "severity": "error",
                        "message": f"SyntaxError: {e.msg}"
                    })
            elif ext in (".js", ".mjs", ".cjs"):
                node_bin = shutil.which("node")
                if node_bin:
                    try:
                        res = subprocess.run([node_bin, "--check", str(fpath)], capture_output=True, text=True, timeout=10)
                        if res.returncode != 0:
                            stderr = res.stderr or ""
                            line_match = re.search(r':(\d+)\n([^\n]+)\n(\s*)\^', stderr)
                            line = int(line_match.group(1)) if line_match else 1
                            col = len(line_match.group(3)) + 1 if line_match else 1
                            msg_match = re.search(r'(SyntaxError:[^\n]+)', stderr)
                            msg = msg_match.group(1) if msg_match else (stderr.strip().splitlines()[-1] if stderr.strip() else "Syntax error")
                            file_diags.append({
                                "file": rel_display,
                                "line": line,
                                "column": col,
                                "severity": "error",
                                "message": msg
                            })
                    except Exception:
                        pass
            elif ext == ".json":
                try:
                    content = fpath.read_text(encoding="utf-8", errors="replace")
                    json.loads(content)
                except json.JSONDecodeError as e:
                    file_diags.append({
                        "file": rel_display,
                        "line": e.lineno,
                        "column": e.colno,
                        "severity": "error",
                        "message": f"JSONDecodeError: {e.msg}"
                    })
            return file_diags

        if target.is_file():
            files_checked = 1
            diagnostics.extend(check_single_file(target))
        else:
            ignore_dirs = {".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__", ".gemini", ".cache"}
            max_scan = 200
            for root, dirs, files in os.walk(target):
                dirs[:] = [d for d in dirs if d not in ignore_dirs and not d.startswith(".")]
                for fname in sorted(files):
                    fp = Path(root) / fname
                    if fp.suffix.lower() in (".py", ".js", ".mjs", ".cjs", ".json"):
                        files_checked += 1
                        diagnostics.extend(check_single_file(fp))
                        if files_checked >= max_scan:
                            break
                if files_checked >= max_scan:
                    break

        error_count = sum(1 for d in diagnostics if d.get("severity") == "error")
        warning_count = sum(1 for d in diagnostics if d.get("severity") == "warning")
        msg = f"Found {len(diagnostics)} issue(s)." if diagnostics else "No diagnostic issues found."

        return json.dumps({
            "success": True,
            "path": str(target),
            "files_checked": files_checked,
            "error_count": error_count,
            "warning_count": warning_count,
            "diagnostics": diagnostics,
            "message": msg
        }, ensure_ascii=False, indent=2)
    except Exception as e:
        return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)


class PersistentBrowserSession:
    """Manages a persistent headless browser session (Playwright) for browser_action."""

    def __init__(self):
        self._lock = threading.Lock()
        self._process: subprocess.Popen | None = None
        atexit.register(self.close)

    def _ensure_running(self) -> subprocess.Popen:
        if self._process is not None and self._process.poll() is None:
            return self._process

        node = shutil.which("node")
        if not node:
            raise RuntimeError("Node.js is not installed or not found in system PATH.")

        runner_js = """
const readline = require('readline');
let playwright;
try {
  playwright = require('playwright');
} catch (e1) {
  try {
    const path = require('path');
    const home = process.env.HOME || process.env.USERPROFILE || '';
    playwright = require(path.join(home, 'zerochat', 'services', 'playwright', 'node_modules', 'playwright'));
  } catch (e2) {
    console.log(JSON.stringify({
      success: false,
      error: "Playwright is not available. Install it with 'npm install playwright' or enable the Playwright MCP service."
    }));
    process.exit(1);
  }
}

(async () => {
  let browser, context, page;
  try {
    const candidates = [
      {},                      // 1. Playwright bundled Chromium
      { channel: 'chrome' },   // 2. System Google Chrome
      { channel: 'msedge' },   // 3. System Microsoft Edge (Windows 10/11)
      { channel: 'chromium' }  // 4. System Chromium (/usr/bin/chromium)
    ];
    let lastErr = null;
    for (const cand of candidates) {
      try {
        browser = await playwright.chromium.launch({ ...cand, headless: true });
        if (browser) break;
      } catch (e) {
        lastErr = e;
      }
    }
    if (!browser) {
      throw lastErr || new Error('No Chromium-based browser found (Playwright, Chrome, Edge, or Chromium).');
    }
    context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
    page = await context.newPage();
  } catch (err) {
    console.log(JSON.stringify({ success: false, error: 'Failed to start browser: ' + err.message }));
    process.exit(1);
  }

  console.log(JSON.stringify({ ready: true }));

  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
  for await (const line of rl) {
    if (!line.trim()) continue;
    let req;
    try {
      req = JSON.parse(line);
    } catch (e) {
      console.log(JSON.stringify({ success: false, error: 'Invalid command JSON' }));
      continue;
    }

    try {
      const act = req.action;
      if (act === 'navigate') {
        if (!req.url) throw new Error("Parameter 'url' is required for action 'navigate'");
        await page.goto(req.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
        console.log(JSON.stringify({
          success: true,
          action: 'navigate',
          url: page.url(),
          title: await page.title()
        }));
      } else if (act === 'screenshot') {
        if (req.url && req.url !== page.url()) {
          await page.goto(req.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
        }
        const buf = await page.screenshot({ fullPage: false });
        console.log(JSON.stringify({
          success: true,
          action: 'screenshot',
          image_base64: buf.toString('base64'),
          mime_type: 'image/png',
          url: page.url(),
          title: await page.title()
        }));
      } else if (act === 'click') {
        if (!req.selector) throw new Error("Parameter 'selector' is required for action 'click'");
        await page.click(req.selector, { timeout: 15000 });
        console.log(JSON.stringify({
          success: true,
          action: 'click',
          selector: req.selector,
          url: page.url()
        }));
      } else if (act === 'fill') {
        if (!req.selector) throw new Error("Parameter 'selector' is required for action 'fill'");
        await page.fill(req.selector, req.value || '', { timeout: 15000 });
        console.log(JSON.stringify({
          success: true,
          action: 'fill',
          selector: req.selector,
          value: req.value || '',
          url: page.url()
        }));
      } else if (act === 'close') {
        await browser.close();
        console.log(JSON.stringify({ success: true, action: 'close' }));
        process.exit(0);
      } else {
        console.log(JSON.stringify({ success: false, error: 'Unsupported action: ' + act }));
      }
    } catch (err) {
      console.log(JSON.stringify({ success: false, error: err.message, action: req.action }));
    }
  }
})();
"""
        proc = subprocess.Popen(
            [node, "-e", runner_js],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            encoding="utf-8",
            bufsize=1
        )
        self._process = proc
        first_line = proc.stdout.readline()
        if not first_line:
            err = proc.stderr.read()
            self._process = None
            raise RuntimeError(f"Failed to initialize headless browser: {err or 'process terminated unexpectedly'}")
        data = json.loads(first_line)
        if not data.get("ready"):
            self._process = None
            raise RuntimeError(data.get("error", "Unknown error starting browser"))

        return proc

    def execute(self, command: dict) -> dict:
        with self._lock:
            try:
                proc = self._ensure_running()
            except Exception as e:
                return {"success": False, "error": str(e)}

            req_json = json.dumps(command, ensure_ascii=False) + "\n"
            try:
                proc.stdin.write(req_json)
                proc.stdin.flush()
                resp_line = proc.stdout.readline()
                if not resp_line:
                    self.close()
                    return {"success": False, "error": "Browser process closed unexpectedly."}
                return json.loads(resp_line)
            except Exception as e:
                self.close()
                return {"success": False, "error": f"Error executing browser action: {e}"}

    def close(self):
        with self._lock:
            if self._process is not None:
                try:
                    if self._process.poll() is None:
                        try:
                            self._process.stdin.write(json.dumps({"action": "close"}) + "\n")
                            self._process.stdin.flush()
                            self._process.wait(timeout=2)
                        except Exception:
                            self._process.kill()
                except Exception:
                    pass
                self._process = None


_BROWSER_SESSION = PersistentBrowserSession()


def browser_action(action: str, url: str | None = None, selector: str | None = None, value: str | None = None) -> str:
    """Control a headless browser for UI testing and visual inspection."""
    try:
        act = (action or "").strip().lower()
        if act not in ("navigate", "screenshot", "click", "fill"):
            return json.dumps({
                "success": False,
                "error": f"Invalid browser action: '{action}'. Valid actions: navigate, screenshot, click, fill."
            }, ensure_ascii=False)

        cmd = {"action": act}
        if url:
            cmd["url"] = str(url).strip()
        if selector:
            cmd["selector"] = str(selector).strip()
        if value is not None:
            cmd["value"] = str(value)

        result = _BROWSER_SESSION.execute(cmd)
        return json.dumps(result, ensure_ascii=False, indent=2)
    except Exception as e:
        return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)


LOCAL_TOOLS_DEFINITIONS = [
    {
        "name": "list_directory",
        "description": "List files and directories in a local directory with safe bounded recursive traversal.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Relative or absolute directory path (defaults to '.')"},
                "recursive": {"type": "boolean", "description": "If true, traverses subdirectories up to depth 3", "default": False}
            }
        }
    },
    {
        "name": "read_file",
        "description": "Read text content from a local file with safe ranges and bounds.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Path to the file to read"},
                "start_line": {"type": "integer", "description": "Starting line number (1-indexed)", "default": 1},
                "end_line": {"type": "integer", "description": "Ending line number (inclusive)"},
                "max_lines": {"type": "integer", "description": "Maximum number of lines to read when end_line is omitted", "default": 500},
                "max_bytes": {"type": "integer", "description": "Maximum byte budget", "default": 100000}
            },
            "required": ["path"]
        }
    },
    {
        "name": "write_file",
        "description": "Create or overwrite a file completely and atomically.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Path to the file to write"},
                "content": {"type": "string", "description": "Full content of the file"}
            },
            "required": ["path", "content"]
        }
    },
    {
        "name": "edit_file",
        "description": "Edit an existing file surgically by replacing old_str with new_str (must match exactly once).",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Path to the file to edit"},
                "old_str": {"type": "string", "description": "Exact snippet to replace (must appear exactly once in the file)"},
                "new_str": {"type": "string", "description": "Replacement snippet"},
                "content": {"type": "string", "description": "Content to write in legacy/compatible mode"},
                "mode": {"type": "string", "enum": ["surgical", "write", "append", "replace_chunk"], "default": "surgical"},
                "target_content": {"type": "string", "description": "Exact target content in replace_chunk mode"}
            },
            "required": ["path"]
        }
    },
    {
        "name": "bash",
        "description": "Execute a command in an interactive persistent shell session (preserves cwd and exported environment variables across calls).",
        "inputSchema": {
            "type": "object",
            "properties": {
                "command": {"type": "string", "description": "Shell command to execute (e.g. npm test, git status)"},
                "timeout_seconds": {"type": "integer", "description": "Timeout in seconds (defaults to 30)", "default": 30}
            },
            "required": ["command"]
        }
    },
    {
        "name": "search_files",
        "description": "Recursively search for plain text or regular expressions across project files.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Plain text string or regular expression to search for"},
                "path": {"type": "string", "description": "Base search directory (defaults to '.')", "default": "."},
                "file_pattern": {"type": "string", "description": "Optional glob filter (e.g. *.js, *.py)"}
            },
            "required": ["query"]
        }
    },
    {
        "name": "execute_command",
        "description": "Execute a shell command and capture stdout/stderr.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "command": {"type": "string", "description": "Command to execute"},
                "cwd": {"type": "string", "description": "Working directory (defaults to '.')"},
                "timeout_seconds": {"type": "integer", "description": "Timeout in seconds (defaults to 60)", "default": 60}
            },
            "required": ["command"]
        }
    },
    {
        "name": "get_diagnostics",
        "description": "Retrieve syntax diagnostics and code errors for a file or the entire workspace.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Path to the file or directory to inspect (defaults to '.')"}
            }
        }
    },
    {
        "name": "browser_action",
        "description": "Control a headless browser (Playwright) for web navigation, UI testing, and visual inspection via screenshots.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["navigate", "screenshot", "click", "fill"],
                    "description": "Action to perform in the browser"
                },
                "url": {"type": "string", "description": "Target URL for navigate or screenshot"},
                "selector": {"type": "string", "description": "CSS selector for click or fill actions"},
                "value": {"type": "string", "description": "Text value to type for fill action"}
            },
            "required": ["action"]
        }
    }
]

LOCAL_TOOL_HANDLERS = {
    "list_directory": list_directory,
    "read_file": read_file,
    "write_file": write_file,
    "edit_file": edit_file,
    "bash": bash,
    "search_files": search_files,
    "execute_command": execute_command,
    "get_diagnostics": get_diagnostics,
    "browser_action": browser_action
}

# ==============================================================================
# Servidor HTTP JSON-RPC 2.0 y SSE con Autenticación por Token
# ==============================================================================

MAX_HTTP_BODY_BYTES = 1024 * 1024
MAX_RPC_METHOD_LENGTH = 128
MAX_TOOL_NAME_LENGTH = 128
MAX_PATH_LENGTH = 4096
MAX_COMMAND_LENGTH = 16384


def validate_local_tool_arguments(tool_name: str, arguments: dict) -> str | None:
    """Valida tipos y límites de las herramientas locales antes de ejecutarlas."""
    schemas = {
        "list_directory": {
            "path": (str, MAX_PATH_LENGTH),
            "recursive": (bool, None)
        },
        "read_file": {
            "path": (str, MAX_PATH_LENGTH),
            "start_line": (int, None),
            "end_line": (int, None),
            "max_lines": (int, None),
            "max_bytes": (int, None)
        },
        "write_file": {
            "path": (str, MAX_PATH_LENGTH),
            "content": (str, MAX_HTTP_BODY_BYTES)
        },
        "edit_file": {
            "path": (str, MAX_PATH_LENGTH),
            "old_str": (str, MAX_HTTP_BODY_BYTES),
            "new_str": (str, MAX_HTTP_BODY_BYTES),
            "content": (str, MAX_HTTP_BODY_BYTES),
            "mode": (str, 32),
            "target_content": (str, MAX_HTTP_BODY_BYTES)
        },
        "bash": {
            "command": (str, MAX_COMMAND_LENGTH),
            "timeout_seconds": (int, None)
        },
        "search_files": {
            "query": (str, 4096),
            "path": (str, MAX_PATH_LENGTH),
            "file_pattern": (str, 256)
        },
        "execute_command": {
            "command": (str, MAX_COMMAND_LENGTH),
            "cwd": (str, MAX_PATH_LENGTH),
            "timeout_seconds": (int, None)
        },
        "get_diagnostics": {
            "path": (str, MAX_PATH_LENGTH)
        },
        "browser_action": {
            "action": (str, 32),
            "url": (str, 4096),
            "selector": (str, 1024),
            "value": (str, MAX_HTTP_BODY_BYTES)
        }
    }
    schema = schemas.get(tool_name)
    if schema is None:
        return None

    for name, value in arguments.items():
        expected = schema.get(name)
        if expected is None:
            return f"Argumento no permitido: {name}"
        expected_type, max_length = expected
        if expected_type is bool:
            if not isinstance(value, bool):
                return f"Tipo inválido para '{name}'"
        else:
            if isinstance(value, bool) or not isinstance(value, expected_type):
                return f"Tipo inválido para '{name}'"
        if max_length is not None and len(value) > max_length:
            return f"'{name}' excede el tamaño máximo permitido"

    required = {
        "read_file": ("path",),
        "write_file": ("path", "content"),
        "edit_file": ("path",),
        "bash": ("command",),
        "search_files": ("query",),
        "execute_command": ("command",),
        "browser_action": ("action",)
    }
    for name in required.get(tool_name, ()):
        if name not in arguments:
            return f"Falta el argumento obligatorio: {name}"

    if tool_name == "edit_file":
        has_surgical = "old_str" in arguments and "new_str" in arguments
        has_legacy = "content" in arguments
        if not has_surgical and not has_legacy:
            return "Faltan argumentos obligatorios: especifica 'old_str' y 'new_str' o 'content'"

    if tool_name == "browser_action":
        if arguments.get("action") not in ("navigate", "screenshot", "click", "fill"):
            return f"Acción de navegador no permitida: {arguments.get('action')}"

    return None

def sanitize_log_path(raw_path: str) -> str:
    """Oculta tokens de sesión o parámetros sensibles en la query string para logs seguros."""
    if not raw_path or "?" not in raw_path:
        return raw_path or "/"
    path, query = raw_path.split("?", 1)
    safe_query = re.sub(r'(token=)[^&]+', r'\1***', query, flags=re.IGNORECASE)
    return f"{path}?{safe_query}"


def format_log_error(msg: str, max_len: int = 160) -> str:
    """Limpia y trunca mensajes de error para mantener el log en una sola línea legible."""
    if not msg:
        return ""
    cleaned = " ".join(str(msg).strip().splitlines())
    if len(cleaned) > max_len:
        return cleaned[:max_len - 3] + "..."
    return cleaned


def is_allowed_origin(origin: str | None, require_origin: bool = False) -> bool:
    """Verifica si el origen CORS está autorizado."""
    if origin is None or origin == "null":
        return not require_origin
    origin_lower = origin.lower()
    if origin_lower == "https://albalday.github.io" or origin_lower.startswith("https://albalday.github.io/"):
        return True
    if origin_lower == "http://127.0.0.1" or origin_lower.startswith("http://127.0.0.1:"):
        return True
    if origin_lower == "http://localhost" or origin_lower.startswith("http://localhost:"):
        return True
    return False


def public_tool_name(server_id: str, original: str) -> str:
    """Codificación inyectiva de nombres de herramientas MCP idéntica a publicToolName en js/mcp.js."""
    def encode(value: str, tool: bool = False) -> str:
        if not isinstance(value, str) or not value or len(value) > 256:
            raise ValueError("Componente de nombre MCP no válido")
        return "".join(ch if ("a" <= ch <= "y" or "0" <= ch <= "9" or (tool and ch == "_"))
                       else f"z{ord(ch):x}z" for ch in value)
    name = f"mcp_{encode(server_id)}_{encode(original, True)}"
    if len(name) > 64:
        raise ValueError(f"El nombre público de la herramienta MCP excede 64 caracteres: {name}")
    return name


class StdioMcpClient:
    def __init__(self, command: str, args: list[str], cwd: str, env: dict[str, str]):
        self.command = command
        self.args = args
        self.cwd = cwd
        self.env = env
        self.process: subprocess.Popen | None = None
        self._pending: dict[int, queue.Queue] = {}
        self._next = 0
        self._lock = threading.Lock()
        self._alive = False
        self.tools: list[dict] = []

    def running(self) -> bool:
        return self._alive and self.process is not None and self.process.poll() is None

    def start(self, handshake_timeout: int = 30):
        cmd = [self.command] + self.args
        self.process = subprocess.Popen(
            cmd,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            cwd=self.cwd,
            env=self.env,
            text=True,
            encoding="utf-8",
            errors="replace",
            bufsize=1
        )
        self._alive = True
        threading.Thread(target=self._drain_stderr, daemon=True).start()
        threading.Thread(target=self._read_stdout, daemon=True).start()

        self.request("initialize", {
            "protocolVersion": "2024-11-05",
            "capabilities": {},
            "clientInfo": {"name": "zerochat", "version": VERSION}
        }, timeout=handshake_timeout)
        self.notify("notifications/initialized")
        tools_resp = self.request("tools/list", {}, timeout=10)
        self.tools = tools_resp.get("tools", [])

    def _drain_stderr(self):
        if self.process and self.process.stderr:
            for _ in self.process.stderr:
                pass

    def _read_stdout(self):
        try:
            if not self.process or not self.process.stdout:
                return
            for line in self.process.stdout:
                line = line.strip()
                if not line:
                    continue
                try:
                    msg = json.loads(line)
                except Exception:
                    continue
                req_id = msg.get("id")
                if req_id is not None:
                    with self._lock:
                        waiter = self._pending.pop(req_id, None)
                    if waiter:
                        waiter.put(msg)
        finally:
            self._alive = False
            with self._lock:
                pending = list(self._pending.values())
                self._pending.clear()
            for waiter in pending:
                waiter.put({"error": {"message": "MCP process ended unexpectedly"}})

    def request(self, method: str, params: dict, timeout: int = 30) -> dict:
        if not self.running():
            raise RuntimeError("MCP process is not running")
        with self._lock:
            self._next += 1
            req_id = self._next
            waiter = queue.Queue(maxsize=1)
            self._pending[req_id] = waiter
            payload = json.dumps({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) + "\n"
            try:
                self.process.stdin.write(payload)
                self.process.stdin.flush()
            except Exception as exc:
                self._alive = False
                self._pending.pop(req_id, None)
                raise RuntimeError(f"Failed writing to MCP process: {exc}") from exc
        try:
            response = waiter.get(timeout=timeout)
        except queue.Empty as exc:
            with self._lock:
                self._pending.pop(req_id, None)
            raise TimeoutError(f"MCP request timed out: {method}") from exc
        if response.get("error"):
            raise RuntimeError(str(response["error"].get("message", "MCP request failed")))
        return response.get("result", {})

    def notify(self, method: str):
        if self.running():
            with self._lock:
                try:
                    self.process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": method}) + "\n")
                    self.process.stdin.flush()
                except Exception:
                    self._alive = False

    def stop(self):
        self._alive = False
        if not self.process:
            return
        try:
            self.process.terminate()
            self.process.wait(timeout=2)
        except (OSError, subprocess.TimeoutExpired):
            try:
                self.process.kill()
            except OSError:
                pass
        self.process = None
        self.tools = []


class McpServiceManager:
    def __init__(self, services_root: Path | None = None):
        if services_root:
            self.services_root = Path(services_root)
        else:
            candidates = [
                Path.cwd() / "services",
                get_data_dir() / "services"
            ]
            self.services_root = candidates[0] if get_dev_root() is not None and candidates[0].is_dir() else candidates[1]
        self.services_root.mkdir(parents=True, exist_ok=True)
        self.config_file = get_data_dir() / "config" / "services.json"
        self.config_file.parent.mkdir(parents=True, exist_ok=True)
        self.clients: dict[str, StdioMcpClient] = {}
        self.states: dict[str, str] = {}
        self.errors: dict[str, str] = {}
        self._lock = threading.Lock()
        self._ensure_default_services()
        self.services = self._load_services()
        self.preferences = self._load_preferences()

    def _ensure_default_services(self):
        dummy_dir = self.services_root / "dummy_mcp"
        dummy_dir.mkdir(parents=True, exist_ok=True)
        service_json_file = dummy_dir / "service.json"
        dummy_server_file = dummy_dir / "dummy_mcp_server.py"

        if not service_json_file.exists():
            service_json_file.write_text(json.dumps({
                "schemaVersion": 1,
                "id": "dummy_mcp",
                "displayName": {
                    "es": "MCP de prueba",
                    "en": "Test MCP"
                },
                "description": {
                    "es": "Servicio MCP mínimo para comprobar la infraestructura externa.",
                    "en": "Minimal MCP service for verifying the external infrastructure."
                },
                "enabledByDefault": False,
                "transport": "stdio",
                "launch": {
                    "executable": "${pythonExecutable}",
                    "args": ["${serviceDir}/dummy_mcp_server.py"],
                    "cwd": "${serviceDir}",
                    "env": {},
                    "handshakeTimeoutSeconds": 10
                }
            }, indent=2), encoding="utf-8")

        if not dummy_server_file.exists():
            dummy_server_file.write_text('''#!/usr/bin/env python3
import json, sys

def reply(req_id, result=None, error=None):
    resp = {"jsonrpc": "2.0", "id": req_id}
    if error: resp["error"] = error
    else: resp["result"] = result
    sys.stdout.write(json.dumps(resp) + "\\n")
    sys.stdout.flush()

for raw in sys.stdin:
    try: req = json.loads(raw)
    except: continue
    req_id, method, params = req.get("id"), req.get("method"), req.get("params", {})
    if method == "initialize":
        reply(req_id, {
            "protocolVersion": "2024-11-05",
            "serverInfo": {"name": "ZeroChat Dummy MCP", "version": "1.0.0"},
            "capabilities": {"tools": {}}
        })
    elif method == "tools/list":
        reply(req_id, {"tools": [{
            "name": "echo",
            "description": "Echo back a message for testing.",
            "inputSchema": {
                "type": "object",
                "properties": {"message": {"type": "string", "description": "Message to echo."}},
                "required": ["message"]
            }
        }]})
    elif method == "tools/call":
        if params.get("name") != "echo":
            reply(req_id, error={"code": -32601, "message": "Tool not found"})
            continue
        msg = params.get("arguments", {}).get("message", "")
        reply(req_id, {
            "content": [{"type": "text", "text": f"echo: {msg}"}],
            "isError": False
        })
''', encoding="utf-8")

        # 2. playwright
        playwright_dir = self.services_root / "playwright"
        playwright_dir.mkdir(parents=True, exist_ok=True)
        pw_service = playwright_dir / "service.json"
        pw_installer = playwright_dir / "installer.json"
        if not pw_service.exists():
            pw_service.write_text(json.dumps({
                "schemaVersion": 1,
                "id": "playwright",
                "displayName": {
                    "es": "Playwright MCP",
                    "en": "Playwright MCP"
                },
                "description": {
                    "es": "Automatización de navegador mediante el servidor MCP oficial de Playwright.",
                    "en": "Browser automation through the official Playwright MCP server."
                },
                "enabledByDefault": False,
                "transport": "stdio",
                "launch": {
                    "executable": "${nodeExecutable}",
                    "args": ["${serviceDir}/node_modules/@playwright/mcp/cli.js", "--browser=chromium"],
                    "cwd": "${serviceDir}",
                    "env": {},
                    "handshakeTimeoutSeconds": 30
                },
                "options": [
                    {
                        "id": "headless",
                        "type": "boolean",
                        "label": {
                            "es": "Navegación en segundo plano (Headless)",
                            "en": "Headless background mode"
                        },
                        "description": {
                            "es": "Desactívalo para ver la ventana del navegador durante la automatización",
                            "en": "Disable to display the browser window during automation"
                        },
                        "default": True,
                        "argsWhenTrue": ["--headless"],
                        "argsWhenFalse": []
                    }
                ]
            }, indent=2), encoding="utf-8")
        if not pw_installer.exists():
            pw_installer.write_text(json.dumps({
                "schemaVersion": 1,
                "type": "npm",
                "product": {
                    "package": "@playwright/mcp",
                    "version": "0.0.81",
                    "browser": "chromium"
                }
            }, indent=2), encoding="utf-8")

        # 3. memory
        memory_dir = self.services_root / "memory"
        memory_dir.mkdir(parents=True, exist_ok=True)
        mem_service = memory_dir / "service.json"
        mem_installer = memory_dir / "installer.json"
        if not mem_service.exists():
            mem_service.write_text(json.dumps({
                "schemaVersion": 1,
                "id": "memory",
                "displayName": {
                    "es": "Memoria y Grafos (Knowledge Graph)",
                    "en": "Memory & Knowledge Graph"
                },
                "description": {
                    "es": "Almacenamiento persistente de entidades, preferencias y contexto histórico estructurado en un grafo de conocimiento.",
                    "en": "Persistent storage of entities, preferences, and historical context structured as a knowledge graph."
                },
                "enabledByDefault": False,
                "transport": "stdio",
                "launch": {
                    "executable": "${nodeExecutable}",
                    "args": ["${serviceDir}/node_modules/@modelcontextprotocol/server-memory/dist/index.js"],
                    "cwd": "${serviceDir}",
                    "env": {
                        "MEMORY_FILE_PATH": "${serviceDir}/memory.jsonl"
                    },
                    "handshakeTimeoutSeconds": 30
                }
            }, indent=2), encoding="utf-8")
        if not mem_installer.exists():
            mem_installer.write_text(json.dumps({
                "schemaVersion": 1,
                "type": "npm",
                "product": {
                    "package": "@modelcontextprotocol/server-memory",
                    "version": "2026.8.31"
                }
            }, indent=2), encoding="utf-8")

        # 4. lsp
        lsp_dir = self.services_root / "lsp"
        lsp_dir.mkdir(parents=True, exist_ok=True)
        lsp_service = lsp_dir / "service.json"
        lsp_installer = lsp_dir / "installer.json"
        if not lsp_service.exists():
            lsp_service.write_text(json.dumps({
                "schemaVersion": 1,
                "id": "lsp",
                "displayName": {
                    "es": "LSP y Navegación de Código",
                    "en": "LSP & Code Intelligence"
                },
                "description": {
                    "es": "Servidor de protocolos de lenguaje (LSP): salto a definiciones, búsqueda de símbolos, referencias e inspección de tipos sin sobrecargar el contexto.",
                    "en": "Language Server Protocol (LSP) server: jump to definitions, symbol search, references, and type inspection without context overload."
                },
                "enabledByDefault": False,
                "transport": "stdio",
                "launch": {
                    "executable": "${nodeExecutable}",
                    "args": ["${serviceDir}/node_modules/@axivo/mcp-lsp/dist/index.js"],
                    "cwd": "${serviceDir}",
                    "env": {},
                    "handshakeTimeoutSeconds": 30
                }
            }, indent=2), encoding="utf-8")
        if not lsp_installer.exists():
            lsp_installer.write_text(json.dumps({
                "schemaVersion": 1,
                "type": "npm",
                "product": {
                    "package": "@axivo/mcp-lsp",
                    "version": "1.0.5"
                }
            }, indent=2), encoding="utf-8")

    def _load_services(self) -> dict[str, dict]:
        servers = {}
        for directory in sorted(self.services_root.iterdir()):
            if not directory.is_dir():
                continue
            service_file = directory / "service.json"
            if not service_file.exists():
                continue
            try:
                server = json.loads(service_file.read_text(encoding="utf-8"))
                server_id = server.get("id") or directory.name.replace(".mcp", "")
                server["id"] = server_id
                server["_directory"] = directory
                servers[server_id] = server
            except Exception:
                continue
        return servers

    def _load_preferences(self) -> dict:
        if self.config_file.exists():
            try:
                return json.loads(self.config_file.read_text(encoding="utf-8"))
            except Exception:
                return {}
        return {}

    def _save_preferences(self):
        try:
            tmp = self.config_file.with_suffix(".tmp")
            tmp.write_text(json.dumps(self.preferences, indent=2), encoding="utf-8")
            tmp.replace(self.config_file)
        except Exception:
            pass

    def list_servers(self) -> list[dict]:
        self.services = self._load_services()
        result = []
        for server_id, server in self.services.items():
            client = self.clients.get(server_id)
            running = bool(client and client.running())
            pref = self.preferences.get(server_id, {})
            result.append({
                "id": server_id,
                "displayName": server.get("displayName", {}),
                "description": server.get("description", {}),
                "enabled": pref.get("enabled", server.get("enabledByDefault", False)),
                "status": "running" if running else self.states.get(server_id, "stopped"),
                "toolCount": len(client.tools) if running else 0,
                "error": self.errors.get(server_id),
                "options": server.get("options", []),
                "userOptions": pref.get("options", {})
            })
        return result

    def _expand(self, value: str, values: dict[str, str]) -> str:
        if not isinstance(value, str):
            raise ValueError("Invalid process argument")
        for key, replacement in values.items():
            value = value.replace("${" + key + "}", str(replacement))
        return value

    def _prepare_service(self, server_id: str, server: dict) -> dict[str, str]:
        service_dir = server["_directory"]
        installer_file = service_dir / "installer.json"
        node = shutil.which("node") or "node"
        npm = shutil.which("npm") or "npm"

        if installer_file.exists():
            marker = service_dir / ".installed.json"
            try:
                installer = json.loads(installer_file.read_text(encoding="utf-8"))
            except Exception as e:
                raise RuntimeError(f"Error leyendo installer.json: {e}")
            kind = installer.get("type", "npm")
            if kind == "npm":
                product = installer.get("product", {})
                package = product.get("package")
                version = product.get("version")
                if not package or not version:
                    raise RuntimeError("El instalador npm debe definir package y version")
                if not shutil.which("node") or not shutil.which("npm"):
                    raise RuntimeError("Node.js 18+ y npm son necesarios para instalar este servicio MCP")

                needs_install = not marker.exists()
                if not needs_install:
                    try:
                        installation = json.loads(marker.read_text(encoding="utf-8"))
                        if installation.get("package") != package or installation.get("version") != version:
                            needs_install = True
                    except Exception:
                        needs_install = True

                if needs_install:
                    self.states[server_id] = "installing"
                    manifest = service_dir / "package.json"
                    manifest.write_text(json.dumps({"private": True, "dependencies": {package: version}}, indent=2), encoding="utf-8")
                    res = subprocess.run([npm, "install", "--ignore-scripts", "--no-audit", "--no-fund"], cwd=service_dir, capture_output=True, text=True, timeout=600)
                    if res.returncode != 0:
                        raise RuntimeError(f"Fallo instalando dependencias npm: {res.stderr or res.stdout}")
                    installation = {"type": "npm", "package": package, "version": version, "nodeExecutable": node}
                    browser = product.get("browser")
                    if browser:
                        playwright_cli = service_dir / "node_modules" / "playwright" / "cli.js"
                        if playwright_cli.is_file():
                            subprocess.run([node, str(playwright_cli), "install", browser], cwd=service_dir, capture_output=True, text=True, timeout=600)
                            installation["browser"] = browser
                    marker.write_text(json.dumps(installation, indent=2), encoding="utf-8")

        return {
            "serviceDir": str(service_dir),
            "pythonExecutable": str(get_venv_python(get_venv_dir())) if get_venv_python(get_venv_dir()).is_file() else sys.executable,
            "nodeExecutable": node
        }

    def start(self, server_id: str) -> list[dict]:
        with self._lock:
            self.services = self._load_services()
            server = self.services.get(server_id)
            if not server:
                raise KeyError(f"Servidor MCP desconocido: {server_id}")
            current = self.clients.get(server_id)
            if current and current.running():
                return self.list_servers()
            self.states[server_id] = "starting"
            try:
                service_dir = server["_directory"]
                values = self._prepare_service(server_id, server)
                pref = self.preferences.get(server_id, {})
                user_opts = pref.get("options", {})
                for opt in server.get("options", []):
                    opt_id = opt.get("id")
                    if opt_id:
                        val = user_opts.get(opt_id, opt.get("default"))
                        values[f"option:{opt_id}"] = str(val)

                launch = server.get("launch", {})
                command = self._expand(launch.get("executable", sys.executable), values)
                args = [self._expand(arg, values) for arg in launch.get("args", [])]
                for opt in server.get("options", []):
                    opt_id = opt.get("id")
                    if not opt_id:
                        continue
                    val = user_opts.get(opt_id, opt.get("default"))
                    if opt.get("type") == "boolean":
                        extra = opt.get("argsWhenTrue", []) if val else opt.get("argsWhenFalse", [])
                        args.extend([self._expand(a, values) for a in extra])

                env = os.environ.copy()
                for k, v in launch.get("env", {}).items():
                    env[k] = self._expand(v, values)

                client = StdioMcpClient(command, args, str(service_dir), env)
                client.start(int(launch.get("handshakeTimeoutSeconds", 15)))
                self.clients[server_id] = client
                self.states[server_id] = "running"
                self.errors.pop(server_id, None)
                entry = self.preferences.setdefault(server_id, {})
                entry["enabled"] = True
                self._save_preferences()
            except Exception as exc:
                self.states[server_id] = "error"
                self.errors[server_id] = str(exc)
                if server_id in self.clients:
                    self.clients.pop(server_id).stop()
            return self.list_servers()

    def stop(self, server_id: str) -> list[dict]:
        with self._lock:
            client = self.clients.pop(server_id, None)
            if client:
                client.stop()
            self.states[server_id] = "stopped"
            self.errors.pop(server_id, None)
            entry = self.preferences.setdefault(server_id, {})
            entry["enabled"] = False
            self._save_preferences()
            return self.list_servers()

    def configure(self, server_id: str, options: dict | None = None) -> list[dict]:
        with self._lock:
            self.services = self._load_services()
            if server_id not in self.services:
                raise KeyError(f"Servidor MCP desconocido: {server_id}")
            entry = self.preferences.setdefault(server_id, {})
            if options is not None:
                opts = entry.setdefault("options", {})
                opts.update(options)
            self._save_preferences()
            return self.list_servers()

    def tools(self) -> list[dict]:
        aggregated = []
        with self._lock:
            for server_id, client in self.clients.items():
                if not client.running():
                    continue
                for t in client.tools:
                    try:
                        pname = public_tool_name(server_id, t["name"])
                        tcopy = dict(t)
                        tcopy["name"] = pname
                        tcopy["metadata"] = {
                            "mcpServerId": server_id,
                            "originalName": t["name"]
                        }
                        aggregated.append(tcopy)
                    except Exception:
                        continue
        return aggregated

    def call(self, public_name: str, arguments: dict) -> dict:
        with self._lock:
            for server_id, client in self.clients.items():
                if not client.running():
                    continue
                for t in client.tools:
                    if public_tool_name(server_id, t["name"]) == public_name:
                        return client.request("tools/call", {"name": t["name"], "arguments": arguments})
        raise ValueError(f"Herramienta externa '{public_name}' no disponible o servidor detenido.")

    def close(self):
        with self._lock:
            for client in list(self.clients.values()):
                client.stop()
            self.clients.clear()


GLOBAL_MCP_MANAGER = McpServiceManager()

DEFAULT_HEARTBEAT_TIMEOUT = float(os.environ.get("ZEROCHAT_HEARTBEAT_TIMEOUT", "60.0"))
DEFAULT_HEARTBEAT_GRACE = float(os.environ.get("ZEROCHAT_HEARTBEAT_GRACE", "45.0"))
DEFAULT_HEARTBEAT_POLL = float(os.environ.get("ZEROCHAT_HEARTBEAT_POLL", "5.0"))
HEARTBEAT_LAST_SEEN = 0.0
HEARTBEAT_INITIALIZED = False
HEARTBEAT_WATCHDOG_STOP = threading.Event()
_SERVER_SHUTTING_DOWN = threading.Event()


def mark_browser_active():
    """Registra la presencia activa del navegador ante cualquier petición válida."""
    global HEARTBEAT_LAST_SEEN, HEARTBEAT_INITIALIZED
    HEARTBEAT_LAST_SEEN = time.monotonic()
    HEARTBEAT_INITIALIZED = True


def stop_zerochat_server(server: ThreadingHTTPServer):
    """Detiene limpiamente el servidor y todos los subsistemas evitando reentradas."""
    if _SERVER_SHUTTING_DOWN.is_set():
        return
    _SERVER_SHUTTING_DOWN.set()
    HEARTBEAT_WATCHDOG_STOP.set()
    GLOBAL_MCP_MANAGER.close()
    threading.Thread(target=server.shutdown, daemon=True).start()


def heartbeat_watchdog(server: ThreadingHTTPServer, initial_grace_seconds: float = DEFAULT_HEARTBEAT_GRACE, inactivity_timeout_seconds: float = DEFAULT_HEARTBEAT_TIMEOUT, require_initial_connection: bool = True):
    """
    Supervisa la presencia de la pestaña del navegador mediante latidos HTTP.
    Si el navegador se cierra o deja de emitir latidos, detiene el servidor automáticamente.
    """
    start_time = time.monotonic()
    poll_interval = min(DEFAULT_HEARTBEAT_POLL, max(0.02, inactivity_timeout_seconds / 2))
    while not HEARTBEAT_WATCHDOG_STOP.is_set():
        if HEARTBEAT_WATCHDOG_STOP.wait(timeout=poll_interval):
            break

        now = time.monotonic()

        # 1. Periodo de gracia inicial (solo si zerochat abrió el navegador)
        if not HEARTBEAT_INITIALIZED:
            if require_initial_connection and (now - start_time > initial_grace_seconds):
                console_log(f"[{time.strftime('%H:%M:%S')}] Tiempo de espera del navegador agotado ({initial_grace_seconds:.0f}s). Deteniendo servidor ZeroChat...", flush=True)
                stop_zerochat_server(server)
                break
            continue

        # 2. Inactividad tras haber recibido latidos
        if now - HEARTBEAT_LAST_SEEN > inactivity_timeout_seconds:
            console_log(f"[{time.strftime('%H:%M:%S')}] Navegador desconectado (cierre detectado). Deteniendo servidor ZeroChat...", flush=True)
            stop_zerochat_server(server)
            break


DEV_CONTENT_TYPES = {
    ".html": "text/html; charset=utf-8",
    ".js": "application/javascript; charset=utf-8",
    ".mjs": "application/javascript; charset=utf-8",
    ".css": "text/css; charset=utf-8",
    ".json": "application/json; charset=utf-8",
    ".webmanifest": "application/manifest+json; charset=utf-8",
    ".svg": "image/svg+xml",
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".ico": "image/x-icon",
    ".txt": "text/plain; charset=utf-8",
    ".map": "application/json",
}


class ZeroChatServerHandler(BaseHTTPRequestHandler):
    server_version = f"ZeroChatServer/{VERSION}"

    def _log_req(self, method: str, detail: str):
        now = time.strftime("%H:%M:%S")
        console_log(f"[{now}] --> {method} {detail}", flush=True)

    def _log_res(self, status: int, detail: str, duration_ms: float, error_info: str = ""):
        now = time.strftime("%H:%M:%S")
        status_text = {
            200: "200 OK",
            204: "204 No Content",
            400: "400 Bad Request",
            401: "401 Unauthorized",
            403: "403 Forbidden",
            404: "404 Not Found",
            500: "500 Internal Server Error",
        }.get(status, str(status))
        err_suffix = f" - ERROR: {format_log_error(error_info)}" if error_info else ""
        console_log(f"[{now}] <-- {status_text} {detail}{err_suffix} ({duration_ms:.1f}ms)", flush=True)

    def serve_static_file(self, rel_path: str) -> bool:
        """Sirve recursos estáticos solo desde el repositorio de desarrollo."""
        static_root = get_static_root()
        if not static_root:
            return False

        clean_rel = rel_path.split("?", 1)[0].lstrip("/")
        if not clean_rel:
            return False

        target_path = (static_root / clean_rel).resolve()
        try:
            rel_parts = target_path.relative_to(static_root.resolve()).parts
        except ValueError:
            return False

        # Whitelist de archivos y carpetas autorizados para servir la interfaz web
        is_allowed_static = (
            clean_rel == "zerochat.html" or
            clean_rel in ("manifest.webmanifest", "sw.js", "favicon.ico") or
            clean_rel.startswith("js/") or
            clean_rel.startswith("css/") or
            clean_rel.startswith("help/")
        )
        if not is_allowed_static:
            return False

        # Protección: bloquear archivos ocultos, datos y dependencias locales.
        for part in rel_parts:
            if part.startswith(".") and part != ".":
                return False
            if part in ("zerochat", "node_modules", ".git", "tests"):
                return False

        if not target_path.is_file():
            return False

        ext = target_path.suffix.lower()
        content_type = DEV_CONTENT_TYPES.get(ext, "application/octet-stream")
        try:
            data = target_path.read_bytes()
        except Exception:
            return False

        mark_browser_active()
        self.send_response(200)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(data)))
        if clean_rel == "sw.js":
            self.send_header("Service-Worker-Allowed", "/")
        self.send_cors_headers()
        self.end_headers()
        self.wfile.write(data)
        return True

    def send_cors_headers(self):
        origin = self.headers.get("Origin")
        if not is_allowed_origin(origin, require_origin=True):
            return
        self.send_header("Access-Control-Allow-Origin", origin if origin else "*")
        self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, X-ZeroChat-Token, X-ZeroChat-Client")
        self.send_header("Access-Control-Allow-Private-Network", "true")

    def verify_token(self) -> bool:
        """Comprueba el token de sesión exclusivamente en cabeceras HTTP."""
        auth_header = self.headers.get("Authorization", "")
        token_candidate = None
        if auth_header.startswith("Bearer "):
            token_candidate = auth_header[7:].strip()
        elif "X-ZeroChat-Token" in self.headers:
            token_candidate = self.headers.get("X-ZeroChat-Token", "").strip()

        if not token_candidate:
            return False
        is_valid = hmac.compare_digest(token_candidate, SESSION_TOKEN)
        if is_valid:
            mark_browser_active()
        return is_valid

    def do_OPTIONS(self):
        t0 = time.monotonic()
        safe_path = sanitize_log_path(self.path)
        path_clean = self.path.split("?", 1)[0].rstrip("/")
        is_heartbeat = path_clean == "/zerochat/heartbeat"
        if not is_heartbeat:
            self._log_req("OPTIONS", safe_path)
        origin = self.headers.get("Origin")
        if not is_allowed_origin(origin, require_origin=True):
            self.send_response(403)
            self.end_headers()
            self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
            return
        self.send_response(204)
        self.send_cors_headers()
        self.end_headers()
        if not is_heartbeat:
            self._log_res(204, safe_path, (time.monotonic() - t0) * 1000)

    def _send_json_response(self, status: int, data: dict):
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_cors_headers()
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        t0 = time.monotonic()
        safe_path = sanitize_log_path(self.path)
        path_clean = self.path.split("?", 1)[0].rstrip("/")
        is_heartbeat = path_clean == "/zerochat/heartbeat"

        origin = self.headers.get("Origin")
        if not is_allowed_origin(origin):
            if not is_heartbeat:
                self._log_req("GET", safe_path)
            self.send_response(403)
            self.end_headers()
            self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
            return

        # Servir archivos estáticos del repositorio si estamos en entorno de desarrollo
        if path_clean and self.serve_static_file(path_clean):
            self._log_req("GET", safe_path)
            self._log_res(200, safe_path, (time.monotonic() - t0) * 1000)
            return

        if not is_heartbeat:
            self._log_req("GET", safe_path)

        if not self.verify_token():
            err_msg = json.dumps({"error": "Unauthorized: invalid or missing session token"}).encode("utf-8")
            self.send_response(401)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(err_msg)))
            self.send_cors_headers()
            self.end_headers()
            self.wfile.write(err_msg)
            self._log_res(401, safe_path, (time.monotonic() - t0) * 1000, "Token de sesión ausente o inválido")
            return

        if is_heartbeat:
            mark_browser_active()
            self._send_json_response(200, {"ok": True})
            return

        accept = self.headers.get("Accept", "")
        if "/sse" in self.path or "text/event-stream" in accept:
            self.send_response(200)
            self.send_header("Content-Type", "text/event-stream")
            self.send_header("Cache-Control", "no-cache")
            self.send_header("Connection", "keep-alive")
            self.send_cors_headers()
            self.end_headers()
            endpoint_data = b"/mcp/external" if "/mcp/external" in self.path else b"/"
            self.wfile.write(b"event: endpoint\r\ndata: " + endpoint_data + b"\r\n\r\n")
            self.wfile.flush()
            self._log_res(200, f"{safe_path} [SSE canal activo]", (time.monotonic() - t0) * 1000)
            return

        # Status general
        res_data = json.dumps({
            "status": "active",
            "server": "ZeroChat Local Server",
            "version": VERSION,
            "tools_count": len(LOCAL_TOOLS_DEFINITIONS),
            "os": DETECTED_OS
        }, ensure_ascii=False, indent=2).encode("utf-8")

        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(res_data)))
        self.send_cors_headers()
        self.end_headers()
        self.wfile.write(res_data)
        self._log_res(200, safe_path, (time.monotonic() - t0) * 1000)

    def do_POST(self):
        t0 = time.monotonic()
        safe_path = sanitize_log_path(self.path)
        origin = self.headers.get("Origin")
        if not is_allowed_origin(origin):
            self._log_req("POST", safe_path)
            self.send_response(403)
            self.end_headers()
            self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
            return

        if not self.verify_token():
            self._log_req("POST", safe_path)
            err_msg = json.dumps({
                "jsonrpc": "2.0",
                "id": None,
                "error": {"code": -32000, "message": "Unauthorized: invalid or missing session token"}
            }).encode("utf-8")
            self.send_response(401)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(err_msg)))
            self.send_cors_headers()
            self.end_headers()
            self.wfile.write(err_msg)
            self._log_res(401, safe_path, (time.monotonic() - t0) * 1000, "Token de sesión ausente o inválido")
            return

        raw_content_len = self.headers.get("Content-Length")
        try:
            content_len = int(raw_content_len)
        except (TypeError, ValueError):
            self._log_req("POST", safe_path)
            self._send_json_response(400, {"error": "Invalid Content-Length"})
            self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Content-Length inválido")
            return
        if content_len < 0:
            self._log_req("POST", safe_path)
            self._send_json_response(400, {"error": "Invalid Content-Length"})
            self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Content-Length negativo")
            return
        if content_len > MAX_HTTP_BODY_BYTES:
            self._log_req("POST", safe_path)
            if content_len <= MAX_HTTP_BODY_BYTES * 2:
                try:
                    self.rfile.read(content_len)
                except Exception:
                    pass
            self.close_connection = True
            self._send_json_response(413, {"error": "Request body too large"})
            self._log_res(413, safe_path, (time.monotonic() - t0) * 1000, "Cuerpo HTTP excede el límite")
            return
        post_data = self.rfile.read(content_len)

        try:
            req = json.loads(post_data.decode("utf-8"))
        except Exception as err:
            self._log_req("POST", safe_path)
            err_resp = json.dumps({
                "jsonrpc": "2.0",
                "id": None,
                "error": {"code": -32700, "message": f"Parse error: {str(err)}"}
            }).encode("utf-8")
            self.send_response(400)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_cors_headers()
            self.end_headers()
            self.wfile.write(err_resp)
            self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, f"Error parseando JSON: {err}")
            return

        if not isinstance(req, dict):
            self._send_json_response(400, {"error": "Request must be a JSON object"})
            self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "La solicitud JSON no es un objeto")
            return

        req_id = req.get("id")
        method = req.get("method")
        params = req.get("params", {})
        if req.get("jsonrpc") != "2.0":
            self._send_json_response(400, {"error": "Invalid JSON-RPC version"})
            self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Versión JSON-RPC inválida")
            return
        if req_id is not None and (isinstance(req_id, bool) or not isinstance(req_id, (str, int, float))):
            self._send_json_response(400, {"error": "Invalid JSON-RPC id"})
            self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "id JSON-RPC inválido")
            return
        if not isinstance(method, str) or not method or len(method) > MAX_RPC_METHOD_LENGTH:
            self._send_json_response(400, {"error": "Invalid JSON-RPC method"})
            self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Método JSON-RPC inválido")
            return
        if not isinstance(params, dict):
            self._send_json_response(400, {"error": "Invalid JSON-RPC params"})
            self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "params JSON-RPC inválidos")
            return

        if method == "tools/call":
            tool_name = params.get("name")
            tool_args = params.get("arguments", {})
            if not isinstance(tool_name, str) or not tool_name or len(tool_name) > MAX_TOOL_NAME_LENGTH:
                self._send_json_response(400, {"error": "Invalid tool name"})
                self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Nombre de herramienta inválido")
                return
            if not isinstance(tool_args, dict):
                self._send_json_response(400, {"error": "Invalid tool arguments"})
                self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Argumentos de herramienta inválidos")
                return
            tool_args_error = validate_local_tool_arguments(tool_name, tool_args)
            if tool_args_error:
                self._send_json_response(400, {"error": tool_args_error})
                self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, tool_args_error)
                return

        req_path = self.path.split("?", 1)[0].rstrip("/")

        # Determinar etiqueta de seguimiento para la petición y respuesta (sin datos sensibles)
        tool_name = params.get("name", "") if isinstance(params, dict) else ""
        if method == "tools/call" and tool_name:
            action_tag = f"[tools/call: {tool_name}]"
        elif method:
            action_tag = f"[rpc: {method}]"
        elif req_path.startswith("/zerochat/external/servers/"):
            action_tag = f"[REST: {req_path}]"
        else:
            action_tag = f"[{safe_path}]"

        self._log_req("POST", f"{safe_path} {action_tag}")

        if req_id is None and (method or "").startswith("notifications/"):
            self.send_response(204)
            self.send_cors_headers()
            self.end_headers()
            self._log_res(204, f"{safe_path} {action_tag}", (time.monotonic() - t0) * 1000)
            return

        result = None
        error = None
        tool_error_info = ""
        is_external_endpoint = "/mcp/external" in req_path

        if is_external_endpoint:
            if method == "initialize":
                result = {
                    "protocolVersion": "2024-11-05",
                    "serverInfo": {
                        "name": "ZeroChat External MCP Host",
                        "version": VERSION
                    },
                    "capabilities": {
                        "tools": {"listChanged": True}
                    }
                }
            elif method == "tools/list":
                result = {"tools": GLOBAL_MCP_MANAGER.tools()}
            elif method == "tools/call":
                tool_name = params.get("name", "") if isinstance(params, dict) else ""
                tool_args = params.get("arguments", {}) if isinstance(params, dict) else {}
                try:
                    result = GLOBAL_MCP_MANAGER.call(tool_name, tool_args)
                    if isinstance(result, dict) and result.get("isError"):
                        c_list = result.get("content", [])
                        if c_list and isinstance(c_list, list) and isinstance(c_list[0], dict):
                            tool_error_info = c_list[0].get("text", "Error en herramienta MCP externa")
                        else:
                            tool_error_info = "Error en herramienta MCP externa"
                except Exception as ex:
                    tool_error_info = str(ex)
                    result = {
                        "content": [{"type": "text", "text": json.dumps({"success": False, "error": str(ex)}, ensure_ascii=False)}],
                        "isError": True
                    }
            else:
                error = {"code": -32601, "message": f"Método '{method}' no soportado en /mcp/external."}
        else:
            if method == "initialize":
                result = {
                    "protocolVersion": "2024-11-05",
                    "serverInfo": {
                        "name": "ZeroChat Local Server",
                        "version": VERSION
                    },
                    "capabilities": {
                        "tools": {"listChanged": True}
                    }
                }
            elif method == "tools/list":
                result = {"tools": list(LOCAL_TOOLS_DEFINITIONS)}
            elif method == "tools/call":
                tool_name = params.get("name", "") if isinstance(params, dict) else ""
                tool_args = params.get("arguments", {}) if isinstance(params, dict) else {}

                if tool_name in LOCAL_TOOL_HANDLERS:
                    handler = LOCAL_TOOL_HANDLERS[tool_name]
                    try:
                        tool_output_json = handler(**tool_args)
                        is_tool_err = False
                        try:
                            parsed_out = json.loads(tool_output_json)
                            if isinstance(parsed_out, dict) and parsed_out.get("success") is False:
                                is_tool_err = True
                                tool_error_info = str(parsed_out.get("error", "Error en herramienta local"))
                        except Exception:
                            pass

                        result = {
                            "content": [{"type": "text", "text": tool_output_json}],
                            "isError": is_tool_err
                        }
                    except Exception as ex:
                        tool_error_info = str(ex)
                        result = {
                            "content": [{"type": "text", "text": json.dumps({"success": False, "error": str(ex)}, ensure_ascii=False)}],
                            "isError": True
                        }
                elif tool_name.startswith("mcp_"):
                    try:
                        result = GLOBAL_MCP_MANAGER.call(tool_name, tool_args)
                        if isinstance(result, dict) and result.get("isError"):
                            c_list = result.get("content", [])
                            if c_list and isinstance(c_list, list) and isinstance(c_list[0], dict):
                                tool_error_info = c_list[0].get("text", "Error en herramienta MCP")
                            else:
                                tool_error_info = "Error en herramienta MCP"
                    except Exception as ex:
                        tool_error_info = str(ex)
                        result = {
                            "content": [{"type": "text", "text": json.dumps({"success": False, "error": str(ex)}, ensure_ascii=False)}],
                            "isError": True
                        }
                else:
                    error = {"code": -32601, "message": f"Herramienta local '{tool_name}' no encontrada."}
            elif method == "zerochat/external/status":
                result = {
                    "host": "running",
                    "version": VERSION,
                    "servers": GLOBAL_MCP_MANAGER.list_servers()
                }
            elif method == "zerochat/external/servers/start":
                server_id = params.get("serverId") or req.get("serverId")
                servers = GLOBAL_MCP_MANAGER.start(server_id)
                result = {"servers": servers}
            elif method == "zerochat/external/servers/stop":
                server_id = params.get("serverId") or req.get("serverId")
                servers = GLOBAL_MCP_MANAGER.stop(server_id)
                result = {"servers": servers}
            elif method == "zerochat/external/servers/configure":
                server_id = params.get("serverId") or req.get("serverId")
                opts = params.get("options") or req.get("options", {})
                servers = GLOBAL_MCP_MANAGER.configure(server_id, opts)
                result = {"servers": servers}
            else:
                error = {"code": -32601, "message": f"Método '{method}' no soportado."}

        response_payload = {"jsonrpc": "2.0", "id": req_id}
        error_info = ""
        if error:
            response_payload["error"] = error
            error_info = f"[{error.get('code')}] {error.get('message')}"
        else:
            response_payload["result"] = result
            if tool_error_info:
                error_info = tool_error_info

        resp_bytes = json.dumps(response_payload, ensure_ascii=False).encode("utf-8")

        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(resp_bytes)))
        self.send_cors_headers()
        self.end_headers()
        self.wfile.write(resp_bytes)
        self._log_res(200, f"{safe_path} {action_tag}", (time.monotonic() - t0) * 1000, error_info=error_info)

    def log_message(self, format, *args):
        # Silenciar logs ruidosos por defecto
        pass

def is_termux_environment() -> bool:
    """Devuelve si el proceso se ejecuta dentro de la instalación de Termux."""
    termux_prefix = "/data/data/com.termux/files/usr"
    return bool(
        os.environ.get("TERMUX_VERSION")
        or os.environ.get("PREFIX", "").startswith(termux_prefix)
        or sys.prefix.startswith(termux_prefix)
    )


def get_manual_browser_command(url: str) -> str | None:
    """Devuelve un comando pegable para abrir la sesión cuando Termux no pudo hacerlo."""
    if is_termux_environment():
        return shlex.join(["termux-open-url", url])
    return None


def get_termux_open_url_executable() -> str | None:
    """Localiza termux-open-url incluso si el venv recibió un PATH incompleto."""
    if shutil.which("termux-open-url"):
        return "termux-open-url"

    prefix = os.environ.get("PREFIX", "/data/data/com.termux/files/usr")
    executable = Path(prefix) / "bin" / "termux-open-url"
    if executable.is_file():
        return str(executable)
    return None


def browser_launch_diagnostics(attempts: list[str]) -> str:
    """Resume el entorno y los lanzadores comprobados sin exponer la URL de sesión."""
    termux_prefix = os.environ.get("PREFIX", "(no definido)")
    configured_path = os.environ.get("PATH", "(no definido)")
    termux_binary = Path(termux_prefix) / "bin" / "termux-open-url" if termux_prefix != "(no definido)" else None
    lines = [
        f"plataforma={sys.platform}",
        f"termux_detectado={is_termux_environment()}",
        f"TERMUX_VERSION={'definido' if os.environ.get('TERMUX_VERSION') else 'no definido'}",
        f"PREFIX={termux_prefix}",
        f"PATH={configured_path}",
        f"termux-open-url_en_PATH={shutil.which('termux-open-url') or 'no encontrado'}",
        f"termux-open-url_en_PREFIX={str(termux_binary) if termux_binary and termux_binary.is_file() else 'no encontrado'}",
        f"xdg-open={shutil.which('xdg-open') or 'no encontrado'}",
        f"gio={shutil.which('gio') or 'no encontrado'}",
        "intentos=" + ("; ".join(attempts) if attempts else "ninguno"),
    ]
    return "\n  ".join(lines)


def open_browser(url: str) -> bool:
    """
    Abre la URL en el navegador predeterminado del usuario respetando el entorno del sistema.
    En Termux usa termux-open-url para delegar la apertura en Android. En el resto de
    Linux prioriza xdg-open o gio para respetar el gestor de ventanas y mimeapps.list,
    desacoplando el proceso hijo para evitar ruidos en la terminal.
    """
    attempts = []
    if is_termux_environment():
        termux_open_url = get_termux_open_url_executable()
        if not termux_open_url:
            raise FileNotFoundError(
                "Termux fue detectado, pero no se encontró termux-open-url.\n  "
                + browser_launch_diagnostics(["termux-open-url: no localizado"])
            )
        try:
            subprocess.Popen(
                [termux_open_url, url],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                start_new_session=True,
            )
            return True
        except OSError as error:
            raise RuntimeError(
                f"termux-open-url no pudo iniciarse: {type(error).__name__}: {error}\n  "
                + browser_launch_diagnostics(["termux-open-url: error al iniciar"])
            ) from error
    elif sys.platform.startswith("linux"):
        for cmd in ("xdg-open", "gio"):
            if shutil.which(cmd):
                try:
                    args = ["gio", "open", url] if cmd == "gio" else ["xdg-open", url]
                    subprocess.Popen(
                        args,
                        stdout=subprocess.DEVNULL,
                        stderr=subprocess.DEVNULL,
                        start_new_session=True,
                    )
                    return True
                except OSError as error:
                    attempts.append(f"{cmd}: {type(error).__name__}: {error}")
            else:
                attempts.append(f"{cmd}: no encontrado")
    elif sys.platform == "darwin":
        if shutil.which("open"):
            try:
                subprocess.Popen(
                    ["open", url],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    start_new_session=True,
                )
                return True
            except OSError as error:
                attempts.append(f"open: {type(error).__name__}: {error}")
    elif sys.platform == "win32":
        try:
            os.startfile(url)
            return True
        except OSError as error:
            attempts.append(f"os.startfile: {type(error).__name__}: {error}")

    try:
        if webbrowser.open(url):
            return True
    except Exception as error:
        attempts.append(f"webbrowser: {type(error).__name__}: {error}")
        raise RuntimeError(
            "El navegador predeterminado rechazó la URL de ZeroChat.\n  "
            + browser_launch_diagnostics(attempts)
        ) from error
    attempts.append("webbrowser: devolvió False")
    raise RuntimeError(
        "Ningún lanzador de navegador aceptó la URL de ZeroChat.\n  "
        + browser_launch_diagnostics(attempts)
    )


def launch_browser(url: str) -> bool:
    """Abre el navegador en la URL de la sesión y muestra información o diagnóstico en consola."""
    termux_detected = is_termux_environment()
    if termux_detected:
        console_log(f"[{time.strftime('%H:%M:%S')}] Termux detectado; se abrirá mediante termux-open-url.", flush=True)
    console_log(f"[{time.strftime('%H:%M:%S')}] Abriendo navegador en la interfaz configurada...", flush=True)
    try:
        if not open_browser(url):
            raise RuntimeError("El lanzador de navegador devolvió un resultado sin éxito.")
        return True
    except Exception as e:
        console_log(f"[{time.strftime('%H:%M:%S')}] No se pudo abrir el navegador automáticamente: {e}", flush=True)
        console_log("  Traza de diagnóstico:", flush=True)
        traceback.print_exc()
        manual_command = get_manual_browser_command(url)
        if manual_command:
            console_log("  Termux detectado. Prueba este comando exacto:", flush=True)
            console_log(f"  {manual_command}", flush=True)
        return False



# ==============================================================================
# Punto de Entrada Principal (CLI)
# ==============================================================================

def main():
    global ACTIVE_PORT, ACTIVE_HOST, SESSION_TOKEN, CONSOLE_CONTROL

    parser = argparse.ArgumentParser(description=f"ZeroChat Local Server v{VERSION}")
    parser.add_argument("--port", type=int, default=int(os.environ.get("ZEROCHAT_PORT", DEFAULT_PORT)), help=f"Puerto de escucha (default: {DEFAULT_PORT})")
    parser.add_argument("--host", default=os.environ.get("ZEROCHAT_HOST", DEFAULT_HOST), help=f"Host de escucha (default: {DEFAULT_HOST})")
    parser.add_argument("--token", default=None, help="Fijar un token de sesión específico (opcional)")
    parser.add_argument("--ui-url", default=None, help="URL de la interfaz web a abrir (por defecto: interfaz local en desarrollo o GitHub Pages)")
    parser.add_argument("--no-browser", action="store_true", help="No abrir automáticamente el navegador")
    parser.add_argument("--no-exit-on-close", action="store_true", help="No detener el servidor automáticamente al cerrar el navegador")
    parser.add_argument("--no-venv", action="store_true", help="Omitir la comprobación/creación de ~/zerochat/.venv")
    parser.add_argument("--test", action="store_true", help="Ejecutar autocomprobación interna de herramientas")
    parser.add_argument("--version", action="version", version=f"ZeroChat {VERSION}")
    args = parser.parse_args()

    if args.test:
        print(f"[{time.strftime('%H:%M:%S')}] TEST list_directory {'ok' if json.loads(list_directory('.'))['success'] else 'error'}")
        print(f"[{time.strftime('%H:%M:%S')}] TEST read_file {'ok' if json.loads(read_file('package.json', max_lines=5))['success'] else 'error'}")
        print(f"[{time.strftime('%H:%M:%S')}] TEST execute_command {'ok' if json.loads(execute_command('echo hello'))['success'] else 'error'}")
        print(f"[{time.strftime('%H:%M:%S')}] TEST all local tools ready.")
        return

    # 1. Asegurar el entorno MCP aislado en ambos modos de distribución.
    if not args.no_venv:
        ensure_virtual_environment()

    # 2. Detectar entorno de desarrollo y resolver URL de destino
    dev_root = get_dev_root()
    static_root = get_static_root()
    is_dev = dev_root is not None
    is_installed = is_installed_runtime()

    # 3. Comprobar versión remota en segundo plano (solo fuera del entorno de desarrollo local)
    if not is_dev:
        threading.Thread(target=check_version, daemon=True).start()

    ACTIVE_PORT = args.port
    ACTIVE_HOST = args.host
    if args.token:
        SESSION_TOKEN = args.token
    else:
        SESSION_TOKEN = get_daily_token()

    server = ThreadingHTTPServer((ACTIVE_HOST, ACTIVE_PORT), ZeroChatServerHandler)

    if args.ui_url:
        ui_url = args.ui_url
    elif static_root:
        ui_url = f"http://{ACTIVE_HOST}:{ACTIVE_PORT}/zerochat.html"
    else:
        ui_url = DEFAULT_UI_URL

    browser_host = ACTIVE_HOST if ACTIVE_HOST in {"127.0.0.1", "localhost"} else "127.0.0.1"
    target_url = f"{ui_url}#{urlencode({'token': SESSION_TOKEN, 'host': browser_host, 'port': ACTIVE_PORT})}"
    exit_on_close = not args.no_exit_on_close

    print("=" * 64)
    print(f"  ZeroChat Local Server v{VERSION} (UI {UI_VERSION})")
    print(f"  Directorio de trabajo : {Path.cwd()}")
    print(f"  Datos y MCP           : {get_data_dir()}")
    print(f"  Entorno MCP           : {get_venv_dir()}")
    if is_dev:
        print(f"  Modo de ejecución     : Desarrollo local ({dev_root})")
    elif is_installed:
        print("  Modo de ejecución     : Paquete PyPI (GitHub Pages)")
    else:
        print(f"  Modo de ejecución     : Producción (Web universal)")
    print(f"  Servidor HTTP/SSE     : http://{ACTIVE_HOST}:{ACTIVE_PORT}")
    print("  Token de sesión (diario): configurado")
    print(f"  Destino Web           : {ui_url}")
    if exit_on_close:
        print(f"  Auto-cierre           : Activado (al cerrar navegador)")
    else:
        print(f"  Auto-cierre           : Desactivado")
    print("=" * 64, flush=True)

    CONSOLE_CONTROL = ConsoleControl(server, parser, target_url=target_url)
    CONSOLE_CONTROL.start()

    if not args.no_browser:
        launch_browser(target_url)

    if exit_on_close:
        require_initial = not args.no_browser
        threading.Thread(
            target=heartbeat_watchdog,
            args=(server, DEFAULT_HEARTBEAT_GRACE, DEFAULT_HEARTBEAT_TIMEOUT, require_initial),
            daemon=True
        ).start()

    def shutdown(*_):
        console_log(f"\n[{time.strftime('%H:%M:%S')}] Deteniendo servidor ZeroChat...")
        stop_zerochat_server(server)

    signal.signal(signal.SIGINT, shutdown)
    signal.signal(signal.SIGTERM, shutdown)

    try:
        server.serve_forever()
    finally:
        stop_zerochat_server(server)
        server.server_close()
        if CONSOLE_CONTROL:
            CONSOLE_CONTROL.close()
            CONSOLE_CONTROL = None


if __name__ == "__main__":
    main()
