#!/usr/bin/env python3
"""Agente del equipo de VIXORA.

Programa pequeño que corre en un computador del equipo donde Claude Code está
instalado y con la sesión iniciada (`claude` → login con la suscripción del
equipo). Toma trabajos de IA de la cola de VIXORA, los corre con `claude -p` y
devuelve el resultado. VIXORA lo usa cuando NO hay clave de API de Anthropic
configurada (o cuando el admin fija el proveedor en «runner»).

Solo usa la librería estándar de Python 3.10+. Configuración por variables de
entorno o por un `config.json` al lado de este archivo (las variables mandan):

    VIXORA_API            URL del API (por defecto https://api.vixoraai.com)
    VIXORA_RUNNER_TOKEN   token del equipo (Sistema → Agente del equipo → Crear token)
    CLAUDE_BIN            ruta de Claude Code (por defecto lo busca: `claude`)
    CONCURRENCY           trabajos a la vez (por defecto 2)
    MODEL                 fuerza un modelo (sonnet, haiku, opus…); vacío = el que pida VIXORA
    JOB_TIMEOUT_MAX       tope en segundos por trabajo (por defecto 600; los de búsqueda
                          web pueden llegar a 900)

Uso:
    python vixora_agente.py            atiende trabajos hasta Ctrl+C
    python vixora_agente.py --probar   prueba Claude Code en este equipo (no toca VIXORA)

Con ventana: `vixora_agente_app.py` (o «Abrir agente.bat») usa este mismo
archivo como librería: `on_event()` para enterarse de lo que pasa y
`start()` / `stop()` para arrancar y detener el bucle desde otro hilo.

Privacidad: los trabajos llevan datos de clientes. Este programa no los guarda:
cada trabajo corre en una carpeta temporal vacía que se borra al terminar, sin
herramientas (salvo `Read` para las imágenes del propio trabajo) y sin guardar
la sesión de Claude Code.

Búsqueda web (desde 1.2.0): un trabajo con `"tools": "web"` (p. ej. el agente de
ventas buscando inmobiliarias) corre con SOLO `WebSearch` y `WebFetch`
habilitados y pre-autorizados (`--tools` + `--allowedTools`); nunca Bash, Edit,
Write ni Read. El latido anuncia `"caps": ["web"]` solo si esta versión de Claude
Code tiene esas opciones; si no, VIXORA no le manda trabajos web. En la consola solo se registra el tipo de trabajo, su
duración y el resultado (ok/error), nunca el contenido.
"""

from __future__ import annotations

import json
import os
import platform
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any

VERSION = "1.2.0"
HERE = Path(__file__).resolve().parent

try:  # La consola de Windows no siempre es UTF-8.
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
except Exception:  # noqa: BLE001
    pass


# ------------------------------------------------------------------ configuración


def config_path() -> Path:
    return Path(os.environ.get("VIXORA_AGENTE_CONFIG") or (HERE / "config.json"))


def _config_file() -> dict[str, Any]:
    path = config_path()
    if not path.exists():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8-sig"))
        return data if isinstance(data, dict) else {}
    except (OSError, json.JSONDecodeError) as exc:
        print(f"Aviso: no se pudo leer {path.name}: {exc}")
        return {}


_CFG = _config_file()


def cfg(key: str, default: str = "") -> str:
    val = os.environ.get(key)
    if val is None or val == "":
        val = _CFG.get(key)
    return str(val).strip() if val not in (None, "") else default


def _int(key: str, default: int) -> int:
    try:
        return int(cfg(key, str(default)) or default)
    except ValueError:
        return default


API_URL = cfg("VIXORA_API", "https://api.vixoraai.com").rstrip("/")
TOKEN = cfg("VIXORA_RUNNER_TOKEN")
CONCURRENCY = max(1, min(8, _int("CONCURRENCY", 2)))
MODEL = cfg("MODEL")
JOB_TIMEOUT_MAX = max(30, _int("JOB_TIMEOUT_MAX", 600))
POLL_WAIT = 25
HEARTBEAT_EVERY = 30
#: Tope de un trabajo con búsqueda web (buscar y leer varias páginas tarda).
WEB_TIMEOUT_MAX = 900
#: Las ÚNICAS herramientas de un trabajo web.
WEB_TOOLS = "WebSearch,WebFetch"
MAX_IMAGE_BYTES = 15 * 1024 * 1024
# Windows abre una consola por cada proceso hijo: así no parpadea nada.
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0


def find_claude(auto: bool = False) -> str:
    """Claude Code: CLAUDE_BIN, el del PATH, el de la extensión de VS Code o el
    del instalador nativo (~/.local/bin). Con `auto`, ignora CLAUDE_BIN."""
    explicit = "" if auto else cfg("CLAUDE_BIN")
    if explicit:
        return explicit
    found = shutil.which("claude")
    if found:
        return found
    candidates = sorted(
        (Path.home() / ".vscode" / "extensions").glob("anthropic.claude-code-*/resources/native-binary/claude*"),
        reverse=True,
    )
    for c in candidates:
        if c.is_file():
            return str(c)
    for name in ("claude.exe", "claude"):
        local = Path.home() / ".local" / "bin" / name
        if local.exists():
            return str(local)
    return "claude"


CLAUDE_BIN = find_claude()


def reload_config() -> None:
    """Vuelve a leer config.json (la ventana lo cambia sin reiniciar el programa)."""
    global _CFG, API_URL, TOKEN, CONCURRENCY, MODEL, JOB_TIMEOUT_MAX, CLAUDE_BIN
    _CFG = _config_file()
    API_URL = cfg("VIXORA_API", "https://api.vixoraai.com").rstrip("/")
    TOKEN = cfg("VIXORA_RUNNER_TOKEN")
    CONCURRENCY = max(1, min(8, _int("CONCURRENCY", 2)))
    MODEL = cfg("MODEL")
    JOB_TIMEOUT_MAX = max(30, _int("JOB_TIMEOUT_MAX", 600))
    CLAUDE_BIN = find_claude()


def claude_cmd() -> list[str]:
    """Cómo lanzar Claude Code. Un `.py` (solo para pruebas: un Claude falso)
    se corre con este mismo Python."""
    if CLAUDE_BIN.lower().endswith(".py"):
        return [sys.executable, CLAUDE_BIN]
    return [CLAUDE_BIN]


# ------------------------------------------------------------------ consola y avisos


_print_lock = threading.Lock()
_hooks: list[Any] = []
_state = "stopped"


def on_event(fn: Any) -> None:
    """Registra `fn(tipo, datos)`. La ventana se entera así de lo que pasa:

    - `log`       {line}                       cada línea de la consola
    - `state`     {state, reason?}             connecting / online / offline / stopped
    - `signal`    {name?}                      VIXORA contestó (latido o consulta)
    - `job_start` {id, feature, images}
    - `job_end`   {id, feature, ok, secs, error?}

    Nunca viaja el contenido de un trabajo (ni el encargo ni el resultado).
    Se llama desde los hilos de trabajo: quien escucha no debe tardar.
    """
    _hooks.append(fn)


def emit(kind: str, **data: Any) -> None:
    for fn in list(_hooks):
        try:
            fn(kind, data)
        except Exception:  # noqa: BLE001 — un oyente roto no tumba el agente
            pass


def set_state(state: str, **extra: Any) -> None:
    global _state
    with _print_lock:
        changed = state != _state
        _state = state
    if changed:
        emit("state", state=state, **extra)


def log(msg: str) -> None:
    line = f"[{time.strftime('%H:%M:%S')}] {msg}"
    with _print_lock:
        try:
            print(line, flush=True)  # con pythonw no hay consola: no pasa nada
        except Exception:  # noqa: BLE001
            pass
    emit("log", line=line)


# ------------------------------------------------------------------ API de VIXORA


class AuthError(Exception):
    pass


def api(method: str, path: str, payload: Any = None, *, timeout: float = 40, raw: bool = False) -> Any:
    url = API_URL + path
    data = None
    headers = {
        "Authorization": f"Bearer {TOKEN}",
        "User-Agent": f"vixora-agente/{VERSION}",
        "X-Runner-Version": VERSION,
        "Accept": "application/json",
    }
    if payload is not None:
        data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=data, method=method, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            body = resp.read()
    except urllib.error.HTTPError as exc:
        if exc.code in (401, 403):
            raise AuthError("VIXORA rechazó el token (¿revocado o mal copiado?).") from exc
        raise
    if raw:
        return body
    return json.loads(body.decode("utf-8")) if body else {}


_NET_ERRORS = (urllib.error.URLError, TimeoutError, socket.timeout, ConnectionError, OSError)


def api_retry(method: str, path: str, payload: Any = None, *, attempts: int = 4) -> Any:
    """Para entregar resultados: reintenta con espera creciente si falla la red."""
    wait = 2.0
    for i in range(attempts):
        try:
            return api(method, path, payload)
        except AuthError:
            raise
        except urllib.error.HTTPError as exc:
            if exc.code < 500 or i == attempts - 1:
                raise
        except _NET_ERRORS:
            if i == attempts - 1:
                raise
        time.sleep(wait)
        wait = min(wait * 2, 30)
    return None


# ------------------------------------------------------------------ Claude Code


_FLAGS: set[str] = set()
_CLAUDE_VERSION = ""


def detect_claude() -> bool:
    """Lee `claude --help` para usar solo las opciones que esta versión tiene."""
    global _CLAUDE_VERSION
    try:
        out = subprocess.run(
            [*claude_cmd(), "--version"], capture_output=True, text=True, encoding="utf-8",
            errors="replace", timeout=60, creationflags=NO_WINDOW,
        )
        _CLAUDE_VERSION = (out.stdout or out.stderr).strip().splitlines()[0] if (out.stdout or out.stderr) else ""
        helptext = subprocess.run(
            [*claude_cmd(), "--help"], capture_output=True, text=True, encoding="utf-8",
            errors="replace", timeout=60, creationflags=NO_WINDOW,
        ).stdout or ""
    except (OSError, subprocess.SubprocessError) as exc:
        log(f"No se encontró Claude Code ({CLAUDE_BIN}): {exc}")
        return False
    for flag in (
        "--system-prompt", "--tools", "--add-dir", "--no-session-persistence",
        "--strict-mcp-config", "--disable-slash-commands", "--model", "--allowedTools", "--allowed-tools",
    ):
        if flag + " " in helptext or flag + "," in helptext or flag + "\n" in helptext:
            _FLAGS.add(flag)
    return True


def _allowed_flag() -> str | None:
    for flag in ("--allowedTools", "--allowed-tools"):
        if flag in _FLAGS:
            return flag
    return None


def web_capable() -> bool:
    """¿Esta versión de Claude Code puede correr trabajos web con solo WebSearch/WebFetch?"""
    return "--tools" in _FLAGS and _allowed_flag() is not None


def capabilities() -> list[str]:
    """Lo que el latido anuncia a VIXORA."""
    return ["web"] if web_capable() else []


def is_web(job: dict[str, Any]) -> bool:
    return str(job.get("tools") or "") == "web"


def job_timeout(job: dict[str, Any]) -> int:
    cap = max(JOB_TIMEOUT_MAX, WEB_TIMEOUT_MAX) if is_web(job) else JOB_TIMEOUT_MAX
    return max(10, min(int(job.get("timeout_s") or cap), cap))


def build_command(job: dict[str, Any], workdir: str, with_images: bool) -> tuple[list[str], str]:
    """(comando, texto por stdin) para un trabajo."""
    cmd = [*claude_cmd(), "-p", "--output-format", "json"]
    if is_web(job):
        # Solo buscar y leer páginas web. En `-p` lo que no está pre-autorizado
        # se niega solo: por eso además van en --allowedTools. Nada de Bash,
        # Edit, Write ni Read (ni imágenes) en estos trabajos.
        allowed = _allowed_flag()
        if "--tools" not in _FLAGS or not allowed:
            raise RuntimeError("Esta versión de Claude Code no permite limitar las herramientas a la búsqueda web.")
        cmd += ["--tools", WEB_TOOLS, allowed, WEB_TOOLS]
    elif with_images:
        # Solo `Read`, y solo dentro de la carpeta temporal del trabajo (la de
        # trabajo): lo de fuera pediría permiso y en `-p` se niega solo.
        cmd += ["--tools", "Read"]
        if "--add-dir" in _FLAGS:
            cmd += ["--add-dir", workdir]
    else:
        cmd += ["--tools", ""]
    for flag in ("--no-session-persistence", "--strict-mcp-config", "--disable-slash-commands"):
        if flag in _FLAGS:
            cmd.append(flag)
    model = MODEL or (job.get("model") or "")
    if model:
        cmd += ["--model", model]
    system = (job.get("system") or "").strip()
    prompt = job.get("prompt") or ""
    # Un .cmd/.bat (instalación por npm en Windows) pasa los argumentos por
    # cmd.exe, que rompe los saltos de línea: ahí el sistema va en el prompt.
    shim = CLAUDE_BIN.lower().endswith((".cmd", ".bat"))
    if system and "--system-prompt" in _FLAGS and not shim and len(system) < 24000:
        cmd += ["--system-prompt", system]
    elif system:
        prompt = (
            "INSTRUCCIONES (síguelas al pie de la letra):\n\n" + system + "\n\n---\n\nENCARGO:\n\n" + prompt
        )
    return cmd, prompt


def parse_output(stdout: str, stderr: str, code: int) -> dict[str, Any]:
    raw = (stdout or "").strip()
    try:
        event = json.loads(raw)
    except json.JSONDecodeError:
        if code == 0 and raw:
            return {"text": raw}
        return {"error": (stderr or raw or f"Claude salió con código {code}")[-600:]}
    if isinstance(event, list):  # algunas versiones devuelven la lista de eventos
        event = next((e for e in reversed(event) if isinstance(e, dict) and e.get("type") == "result"), {})
    if not isinstance(event, dict):
        return {"error": "Salida de Claude Code no reconocida"}
    if event.get("is_error") or event.get("subtype", "success") != "success":
        return {"error": str(event.get("result") or event.get("error") or "Claude devolvió un error")[-600:]}
    usage = event.get("usage") or {}
    tokens = 0
    for k in ("input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"):
        try:
            tokens += int(usage.get(k) or 0)
        except (TypeError, ValueError):
            pass
    model = MODEL or event.get("model") or None
    if not model and isinstance(event.get("modelUsage"), dict) and event["modelUsage"]:
        model = next(iter(event["modelUsage"]))
    return {"text": event.get("result") or "", "model": model, "tokens": tokens or None}


_inflight: dict[str, subprocess.Popen] = {}
_inflight_lock = threading.Lock()


def run_claude(
    job: dict[str, Any], workdir: str, with_images: bool, stop: threading.Event | None = None
) -> dict[str, Any]:
    stop = stop or _stop
    try:
        cmd, prompt = build_command(job, workdir, with_images and not is_web(job))
    except RuntimeError as exc:
        return {"error": str(exc)}
    timeout = job_timeout(job)
    try:
        proc = subprocess.Popen(
            cmd, cwd=workdir, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            text=True, encoding="utf-8", errors="replace", creationflags=NO_WINDOW,
        )
    except OSError as exc:
        return {"error": f"No se pudo lanzar Claude Code ({CLAUDE_BIN}): {exc}"}
    with _inflight_lock:
        _inflight[job["id"]] = proc
    try:
        out, err = proc.communicate(input=prompt, timeout=timeout)
    except subprocess.TimeoutExpired:
        proc.kill()
        proc.communicate()
        return {"error": f"Claude Code no terminó en {timeout} s"}
    finally:
        with _inflight_lock:
            _inflight.pop(job["id"], None)
    if stop.is_set():
        return {"error": "El agente del equipo se detuvo antes de terminar."}
    return parse_output(out, err, proc.returncode)


# ------------------------------------------------------------------ trabajos


def download_images(job: dict[str, Any], workdir: str) -> list[str]:
    """Baja las imágenes del trabajo a su carpeta. Devuelve los nombres que llegaron."""
    names = []
    for img in job.get("images") or []:
        name = os.path.basename(str(img.get("name") or "imagen.jpg"))
        try:
            if img.get("path"):
                data = api("GET", img["path"], raw=True, timeout=60)
            else:
                url = str(img.get("url") or "")
                if not url.lower().startswith(("http://", "https://")):
                    raise ValueError("URL no permitida")
                req = urllib.request.Request(url, headers={"User-Agent": f"vixora-agente/{VERSION}"})
                with urllib.request.urlopen(req, timeout=60) as resp:
                    data = resp.read(MAX_IMAGE_BYTES + 1)
                if len(data) > MAX_IMAGE_BYTES:
                    raise ValueError("imagen demasiado grande")
            Path(workdir, name).write_bytes(data)
            names.append(name)
        except Exception as exc:  # noqa: BLE001 — sigue sin esa imagen
            log(f"  no se pudo bajar {name}: {str(exc)[:120]}")
    return names


def handle(job: dict[str, Any], stop: threading.Event | None = None) -> None:
    stop = stop or _stop
    jid = job["id"]
    started = time.monotonic()
    web = is_web(job)
    n_img = 0 if web else len(job.get("images") or [])
    feature = str(job.get("feature") or "")
    emit("job_start", id=jid, feature=feature, images=n_img, web=web)
    log(f"trabajo {jid[:8]} · {job.get('feature')}" + (" · búsqueda web" if web else "")
        + (f" · {n_img} imagen(es)" if n_img else "") + " · en curso")
    try:
        with tempfile.TemporaryDirectory(prefix="vixora-agente-") as workdir:
            got = download_images(job, workdir) if n_img else []
            if n_img and len(got) < n_img:
                missing = n_img - len(got)
                job = {**job, "prompt": job.get("prompt", "") + f"\n\n(Nota: {missing} imagen(es) no se pudieron descargar.)"}
            result = run_claude(job, workdir, with_images=bool(got), stop=stop)
    except Exception as exc:  # noqa: BLE001
        result = {"error": f"Error del agente del equipo: {str(exc)[:500]}"}
    secs = time.monotonic() - started
    try:
        api_retry("POST", f"/runner-api/ai-jobs/{jid}/result", result)
    except AuthError as exc:
        log(f"trabajo {jid[:8]} · no se pudo entregar: {exc}")
        emit("job_end", id=jid, feature=feature, ok=False, secs=secs, error="no se pudo entregar")
        _auth_failed(exc, stop, quiet=True)
        return
    except Exception as exc:  # noqa: BLE001
        log(f"trabajo {jid[:8]} · no se pudo entregar: {str(exc)[:200]}")
        emit("job_end", id=jid, feature=feature, ok=False, secs=secs, error="no se pudo entregar")
        return
    state = "ok" if result.get("text") else f"error: {str(result.get('error'))[:160]}"
    log(f"trabajo {jid[:8]} · {job.get('feature')}" + (" · búsqueda web" if web else "") + f" · {state} · {secs:.1f} s")
    emit("job_end", id=jid, feature=feature, ok=bool(result.get("text")), secs=secs,
         error=None if result.get("text") else str(result.get("error"))[:160])


_stop = threading.Event()
_threads: list[threading.Thread] = []
_run_lock = threading.Lock()


def _auth_failed(exc: Exception, stop: threading.Event, quiet: bool = False) -> None:
    """Token rechazado: no tiene sentido seguir preguntando."""
    if not quiet:
        log(str(exc))
    stop.set()
    if stop is _stop:
        set_state("stopped", reason=str(exc))


def worker(n: int, stop: threading.Event | None = None) -> None:
    stop = stop or _stop
    wait = 2.0
    while not stop.is_set():
        try:
            data = api("GET", f"/runner-api/ai-jobs/next?wait={POLL_WAIT}", timeout=POLL_WAIT + 20)
            wait = 2.0
        except AuthError as exc:
            _auth_failed(exc, stop)
            return
        except Exception as exc:  # noqa: BLE001 — red caída, API reiniciando…
            if stop.is_set():
                return
            log(f"sin conexión con VIXORA ({str(exc)[:100]}); reintento en {int(wait)} s")
            set_state("offline")
            stop.wait(wait)
            wait = min(wait * 2, 60)
            continue
        job = (data or {}).get("job")
        if stop.is_set():
            if job:  # llegó justo al detener: que nadie se quede esperándolo
                try:
                    api("POST", f"/runner-api/ai-jobs/{job['id']}/result",
                        {"error": "El agente del equipo se detuvo antes de terminar."}, timeout=10)
                except Exception:  # noqa: BLE001
                    pass
            return
        emit("signal")
        set_state("online")
        if job:
            handle(job, stop)


def heartbeat_loop(stop: threading.Event | None = None) -> None:
    stop = stop or _stop
    while not stop.wait(HEARTBEAT_EVERY):
        send_heartbeat(quiet=True, stop=stop)


def send_heartbeat(quiet: bool = False, stop: threading.Event | None = None) -> dict[str, Any] | None:
    stop = stop or _stop
    with _inflight_lock:
        busy = len(_inflight)
    info = {
        "claude": _CLAUDE_VERSION[:60], "os": f"{platform.system()} {platform.release()}"[:60],
        "python": platform.python_version(), "concurrency": CONCURRENCY, "busy": busy,
    }
    try:
        res = api("POST", "/runner-api/ai-jobs/heartbeat",
                  {"version": VERSION, "host": socket.gethostname()[:160], "info": info, "caps": capabilities()},
                  timeout=20)
        if not stop.is_set():
            emit("signal", name=(res or {}).get("name"))
            set_state("online")
        return res
    except AuthError as exc:
        _auth_failed(exc, stop)
    except Exception as exc:  # noqa: BLE001
        if not quiet:
            log(f"no se pudo hablar con VIXORA: {str(exc)[:160]}")
    return None


def stop_inflight() -> None:
    """Al cerrar: corta lo que esté corriendo y avisa a VIXORA para que nadie espere."""
    with _inflight_lock:
        items = list(_inflight.items())
    for jid, proc in items:
        try:
            proc.kill()
        except Exception:  # noqa: BLE001
            pass
        try:
            api("POST", f"/runner-api/ai-jobs/{jid}/result",
                {"error": "El agente del equipo se detuvo antes de terminar."}, timeout=10)
        except Exception:  # noqa: BLE001
            pass


# ------------------------------------------------------------------ arranque


def claude_info() -> tuple[str, str]:
    """(ruta, versión) de Claude Code; la versión, tras `detect_claude()`."""
    return CLAUDE_BIN, _CLAUDE_VERSION


def probar_claude() -> dict[str, Any]:
    """La prueba de `--probar`, sin imprimir: {ok, text, error, secs, model, version}."""
    with tempfile.TemporaryDirectory(prefix="vixora-agente-") as d:
        t = time.monotonic()
        res = run_claude(
            {"id": "prueba", "system": "Responde en una sola línea.", "prompt": "Responde solo: OK", "model": "haiku",
             "timeout_s": 120},
            d, with_images=False, stop=threading.Event(),
        )
    return {
        "ok": bool(res.get("text")), "text": (res.get("text") or "").strip()[:200], "error": res.get("error"),
        "secs": time.monotonic() - t, "model": res.get("model"), "version": _CLAUDE_VERSION,
    }


def probar() -> int:
    """Prueba local: ¿Claude Code responde en este equipo? No toca VIXORA."""
    print(f"Claude Code: {CLAUDE_BIN}")
    if not detect_claude():
        return 1
    print(f"Versión: {_CLAUDE_VERSION or '¿?'}")
    res = probar_claude()
    if res["ok"]:
        print(f"Respuesta: {res['text']}  ({res['secs']:.1f} s, modelo {res.get('model') or '¿?'})")
        print("Claude Code funciona. Ahora pon el token y ejecuta sin --probar.")
        return 0
    print(f"Error: {res.get('error')}")
    print("¿Iniciaste sesión? Abre una terminal, ejecuta `claude` y entra con la cuenta del equipo.")
    return 1


# ------------------------------------------------------------------ una sola instancia

_lock_handle: Any = None


def lock_path() -> Path:
    return config_path().parent / "agente.lock"


def acquire_lock(mode: str = "consola") -> bool:
    """Un agente por carpeta de configuración. El candado es del sistema
    operativo: si el programa muere, se suelta solo."""
    global _lock_handle
    if _lock_handle is not None:
        return True
    try:
        fh = open(lock_path(), "a+", encoding="utf-8")  # noqa: SIM115 — vive lo que el proceso
    except OSError:
        return True  # carpeta de solo lectura: no hay cómo saberlo, se deja pasar
    try:
        if os.name == "nt":
            import msvcrt

            fh.seek(4096)  # lejos del texto, para que el otro pueda leer quién lo tiene
            msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
        else:
            import fcntl

            fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        fh.close()
        return False
    try:
        fh.seek(0)
        fh.truncate()
        fh.write(json.dumps({"pid": os.getpid(), "mode": mode, "since": time.strftime("%Y-%m-%d %H:%M:%S")}))
        fh.flush()
    except OSError:
        pass
    _lock_handle = fh
    return True


def lock_owner() -> dict[str, Any]:
    """Quién tiene el candado ({pid, mode, since}), si se puede leer."""
    try:
        data = json.loads(lock_path().read_text(encoding="utf-8")[:300])
        return data if isinstance(data, dict) else {}
    except (OSError, ValueError):
        return {}


def release_lock() -> None:
    global _lock_handle
    if _lock_handle is not None:
        try:
            _lock_handle.close()
        except OSError:
            pass
        _lock_handle = None


# ------------------------------------------------------------------ como librería (ventana)


def _start_threads(stop: threading.Event) -> list[threading.Thread]:
    threads = [
        threading.Thread(target=worker, args=(i, stop), daemon=True, name=f"trabajo-{i}") for i in range(CONCURRENCY)
    ]
    threads.append(threading.Thread(target=heartbeat_loop, args=(stop,), daemon=True, name="latido"))
    for t in threads:
        t.start()
    return threads


def _shutdown() -> None:
    _stop.set()
    stop_inflight()
    try:  # deja de figurar «en línea» en el panel ya mismo
        api("POST", "/runner-api/ai-jobs/bye", {}, timeout=10)
    except Exception:  # noqa: BLE001
        pass
    log("agente detenido.")
    set_state("stopped")


def running() -> bool:
    return bool(_threads) and not _stop.is_set()


def start() -> tuple[bool, str]:
    """Arranca el agente en segundo plano. Tarda unos segundos (mira Claude Code
    y saluda a VIXORA): llámalo fuera del hilo de la interfaz."""
    global _stop, _threads
    with _run_lock:
        if running():
            return True, ""
        reload_config()
        if not TOKEN:
            return False, "Falta el token. Créalo en el panel (Sistema → Agente del equipo → Crear token)."
        set_state("connecting")
        if not detect_claude():
            set_state("stopped")
            return False, "No se encontró Claude Code. Instálalo o indica su ruta en Configuración."
        _stop = stop = threading.Event()
        first = send_heartbeat(stop=stop)
        if stop.is_set():
            _threads = []
            return False, "VIXORA rechazó el token (¿revocado o mal copiado?)."
        if first:
            log(f"conectado a VIXORA como «{first.get('name')}». Esperando trabajos…")
        else:
            log("VIXORA no responde todavía; se reintentará solo.")
            set_state("offline")
        _threads = _start_threads(stop)
        return True, ""


def stop() -> None:
    """Detiene el agente: corta lo que esté en curso y avisa a VIXORA."""
    global _threads
    with _run_lock:
        if not _threads:
            return
        log("cerrando…")
        _shutdown()
        _threads = []


def main() -> int:
    if "--probar" in sys.argv:
        return probar()
    if "--version" in sys.argv:
        print(VERSION)
        return 0
    print(f"Agente del equipo de VIXORA {VERSION}")
    print(f"  API:         {API_URL}")
    print(f"  Claude Code: {CLAUDE_BIN}")
    print(f"  A la vez:    {CONCURRENCY} trabajo(s)" + (f" · modelo fijo: {MODEL}" if MODEL else ""))
    if not TOKEN:
        print("\nFalta el token. Créalo en el panel (Sistema → Agente del equipo → Crear token) y")
        print("ponlo en config.json (\"VIXORA_RUNNER_TOKEN\") o en la variable VIXORA_RUNNER_TOKEN.")
        return 2
    if not acquire_lock("consola"):
        who = lock_owner()
        what = "la ventana del agente" if who.get("mode") == "ventana" else "otro agente"
        print(f"\nYa hay {what} trabajando con esta carpeta" + (f" (proceso {who['pid']})" if who.get("pid") else "") + ".")
        print("No se abre un segundo: ciérralo primero si quieres usar este.")
        return 3
    if not detect_claude():
        print("\nInstala Claude Code (https://claude.com/claude-code) o indica su ruta en CLAUDE_BIN.")
        return 2
    print(f"  Versión:     {_CLAUDE_VERSION or '¿?'}")
    if os.environ.get("ANTHROPIC_API_KEY"):
        print("  Aviso: hay ANTHROPIC_API_KEY en el entorno; Claude Code la usará en vez de la sesión de la suscripción.")
    first = send_heartbeat()
    if _stop.is_set():
        return 2
    if first:
        log(f"conectado a VIXORA como «{first.get('name')}». Esperando trabajos… (Ctrl+C para salir)")
    else:
        log("VIXORA no responde todavía; se reintentará solo. (Ctrl+C para salir)")
    def _graceful(signum: int, frame: Any) -> None:  # noqa: ARG001
        raise KeyboardInterrupt

    # Ctrl+Break (Windows) y SIGTERM (Linux/macOS, servicios) cierran igual que Ctrl+C.
    for name in ("SIGBREAK", "SIGTERM"):
        if hasattr(signal, name):
            try:
                signal.signal(getattr(signal, name), _graceful)
            except (OSError, ValueError):
                pass
    _start_threads(_stop)
    try:
        while not _stop.is_set():
            _stop.wait(1)
    except KeyboardInterrupt:
        log("cerrando…")
    _shutdown()
    return 0


if __name__ == "__main__":
    sys.exit(main())
