# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Small stdio MCP bridge from cloud Claude Code to a local Claude Code child."""

from __future__ import annotations

import json
import os
import signal
import shutil
import subprocess
import sys
import threading
import time
from typing import Any, Callable

from unsloth_cli.commands.start import (
    _CLAUDE_ENV_UNSET,
    _SUBAGENT_DESCRIPTION,
    _SUBAGENT_INSTRUCTIONS,
    _SUBAGENT_PLAN_DESCRIPTION,
    _SUBAGENT_PLAN_INSTRUCTIONS,
    _claude_flags,
    _claude_local_env,
    _wsl_shim_env,
)

_MAX_RESULT_CHARACTERS = 100_000
_CANCEL_POLL_SECONDS = 0.1
_CANCEL_GRACE_SECONDS = 2.0
# A local server that accepts the connection and then never answers leaves the
# child, and the parent waiting on it, blocked forever. Generous enough not to cut
# a long legitimate run short; 0 restores the unbounded wait.
_DEFAULT_TIMEOUT_SECONDS = 1800.0


def _required_env(name: str) -> str:
    value = os.environ.get(name, "").strip()
    if not value:
        raise RuntimeError(f"Missing {name}.")
    return value


def _timeout_seconds() -> float:
    """Wall-clock cap on one child run; 0 or unparsable means wait forever."""
    raw = os.environ.get("UNSLOTH_CLAUDE_SUBAGENT_TIMEOUT")
    if raw is None or not raw.strip():
        return _DEFAULT_TIMEOUT_SECONDS
    try:
        parsed = float(raw.strip())
    except ValueError:
        return _DEFAULT_TIMEOUT_SECONDS
    return parsed if parsed > 0 else 0.0


def _bounded(text: str) -> str:
    if len(text) <= _MAX_RESULT_CHARACTERS:
        return text
    return text[:_MAX_RESULT_CHARACTERS] + "\n\n[Local agent output truncated]"


def _result_text(stdout: str) -> str:
    lines = [line for line in stdout.splitlines() if line.strip()]
    candidates = [stdout.strip(), *reversed(lines)]
    for candidate in candidates:
        try:
            payload = json.loads(candidate)
        except ValueError:
            continue
        if not isinstance(payload, dict):
            continue
        result = payload.get("result")
        if payload.get("is_error"):
            raise RuntimeError(str(result or "The local Claude agent failed."))
        if isinstance(result, str) and result.strip():
            return _bounded(result.strip())
    raise RuntimeError("The local Claude agent returned no readable result.")


def _stop_child(process: subprocess.Popen) -> None:
    """Stop the Claude child and any tool processes it started."""
    if process.poll() is not None:
        if os.name != "nt":
            # Leader exited, but its tool processes may still be running.
            try:
                os.killpg(process.pid, signal.SIGTERM)
            except OSError:
                return
            time.sleep(_CANCEL_GRACE_SECONDS)
            try:
                os.killpg(process.pid, signal.SIGKILL)
            except OSError:
                pass
        return
    if os.name == "nt":
        try:
            completed = subprocess.run(
                ["taskkill", "/PID", str(process.pid), "/T", "/F"],
                capture_output = True,
                timeout = 15,
                check = False,
            )
        except Exception:
            completed = None
        # A failed taskkill must not leave the child running through the grace wait.
        if (completed is None or completed.returncode != 0) and process.poll() is None:
            process.terminate()
    else:
        try:
            os.killpg(process.pid, signal.SIGTERM)
        except OSError:
            process.terminate()
    try:
        process.wait(timeout = _CANCEL_GRACE_SECONDS)
    except subprocess.TimeoutExpired:
        if os.name == "nt":
            process.kill()
        else:
            try:
                os.killpg(process.pid, signal.SIGKILL)
            except OSError:
                process.kill()
        process.wait()
    else:
        if os.name != "nt":
            # Leader is gone; kill any surviving group members.
            try:
                os.killpg(process.pid, signal.SIGKILL)
            except OSError:
                pass


def run_local_agent(
    task: str,
    cancel_event: threading.Event | None = None,
    read_only: bool = False,
) -> str:
    base = _required_env("UNSLOTH_CLAUDE_SUBAGENT_BASE_URL")
    key = _required_env("UNSLOTH_CLAUDE_SUBAGENT_API_KEY")
    model = _required_env("UNSLOTH_CLAUDE_SUBAGENT_MODEL")
    window = int(os.environ.get("UNSLOTH_CLAUDE_SUBAGENT_CONTEXT_WINDOW", "0") or 0)
    entry = {"id": model, "context_length": window}
    local_env = _claude_local_env(base, key, entry)
    child_env = dict(os.environ)

    executable = shutil.which("claude")
    if executable is None:
        raise RuntimeError("`claude` is not installed or is not on PATH.")
    cancel_event = cancel_event or threading.Event()
    if cancel_event.is_set():
        raise RuntimeError("The local Claude agent was cancelled.")
    command = [
        "claude",
        "--model",
        model,
        *_claude_flags(model),
        "--permission-mode",
        (
            "plan"
            if read_only
            else (
                "bypassPermissions"
                if os.environ.get("UNSLOTH_CLAUDE_SUBAGENT_BYPASS_PERMISSIONS") == "1"
                else "acceptEdits"
            )
        ),
        "--print",
        "--output-format",
        "json",
        "--no-session-persistence",
        # Strip human-blocking tools so the child runs unattended. Only the read-only
        # child's writers bite today, since a --print child is never offered the plan
        # or prompt tools; those are listed anyway so a version that starts offering
        # them cannot stall the subagent. Bash is denied read-only side because plan
        # mode gates it through the same local model, which is not a write barrier.
        "--disallowedTools",
        (
            "AskUserQuestion,EnterPlanMode,Edit,Write,NotebookEdit,Bash"
            if read_only
            else "AskUserQuestion,EnterPlanMode,ExitPlanMode"
        ),
        "--append-system-prompt",
        _SUBAGENT_PLAN_INSTRUCTIONS if read_only else _SUBAGENT_INSTRUCTIONS,
        f"Task: {task}",
    ]
    bridged, wsl_names = _wsl_shim_env(command, local_env, _CLAUDE_ENV_UNSET)
    if wsl_names:
        from unsloth_cli.commands.start import _merge_wslenv

        bridged = {**bridged, "PWD": os.getcwd()}
        child_env["WSLENV"] = _merge_wslenv(child_env.get("WSLENV", ""), wsl_names)
        for name in _CLAUDE_ENV_UNSET:
            child_env[name] = ""
    else:
        for name in _CLAUDE_ENV_UNSET:
            child_env.pop(name, None)
    child_env.update(bridged)
    popen_kwargs: dict[str, Any] = {
        "cwd": os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd(),
        "env": child_env,
        "stdin": subprocess.DEVNULL,
        "stdout": subprocess.PIPE,
        "stderr": subprocess.PIPE,
        "text": True,
    }
    if os.name == "nt":
        popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
    else:
        popen_kwargs["start_new_session"] = True
    process = subprocess.Popen(
        [executable, *command[1:]],
        **popen_kwargs,
    )
    deadline = _timeout_seconds()
    started_at = time.monotonic()
    try:
        while True:
            try:
                stdout, stderr = process.communicate(timeout = _CANCEL_POLL_SECONDS)
                break
            except subprocess.TimeoutExpired:
                if cancel_event.is_set():
                    _stop_child(process)
                    raise RuntimeError("The local Claude agent was cancelled.")
                waited = time.monotonic() - started_at
                if deadline and waited > deadline:
                    _stop_child(process)
                    raise RuntimeError(
                        f"The local Claude agent produced nothing after {waited:.0f}s. "
                        "The local server is likely wedged; check that a model is loaded."
                    )
    except BaseException:
        if process.poll() is None:
            _stop_child(process)
        raise
    if process.returncode != 0:
        detail = stderr.strip() or stdout.strip()
        raise RuntimeError(
            _bounded(detail) or f"Local Claude exited with code {process.returncode}."
        )
    return _result_text(stdout)


def _response(
    request: dict,
    run_agent: Callable[[str], str] = run_local_agent,
    tool_name: str = "unsloth_agent",
    tool_description: str | None = None,
    run_read_only_agent: Callable[[str], str] | None = None,
    read_only_tool_name: str | None = None,
    instructions: str | None = None,
) -> dict | None:
    request_id = request.get("id")
    method = request.get("method")
    if request_id is None:
        return None
    if method == "initialize":
        protocol = (request.get("params") or {}).get("protocolVersion") or "2025-06-18"
        result = {
            "protocolVersion": protocol,
            "capabilities": {"tools": {"listChanged": False}},
            "serverInfo": {"name": "unsloth-local-agent", "version": "1.0.0"},
        }
        if instructions:
            result["instructions"] = instructions
    elif method == "ping":
        result = {}
    elif method == "tools/list":

        def tool_definition(name: str, description: str, read_only: bool) -> dict:
            return {
                "name": name,
                "title": "Unsloth local plan agent" if read_only else "Unsloth local agent",
                "description": description,
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "task": {
                            "type": "string",
                            "description": "The complete task for the local Unsloth agent.",
                        }
                    },
                    "required": ["task"],
                    "additionalProperties": False,
                },
                "annotations": {
                    "readOnlyHint": read_only,
                    "destructiveHint": not read_only,
                    "idempotentHint": read_only,
                    "openWorldHint": True,
                },
                "_meta": {"anthropic/maxResultSizeChars": _MAX_RESULT_CHARACTERS},
            }

        tools = [tool_definition(tool_name, tool_description or _SUBAGENT_DESCRIPTION, False)]
        if read_only_tool_name and run_read_only_agent:
            tools.append(tool_definition(read_only_tool_name, _SUBAGENT_PLAN_DESCRIPTION, True))
        result = {"tools": tools}
    elif method == "tools/call":
        params = request.get("params") or {}
        arguments = params.get("arguments") or {}
        requested_tool = params.get("name")
        selected_agent = (
            run_agent
            if requested_tool == tool_name
            else (
                run_read_only_agent
                if requested_tool == read_only_tool_name and run_read_only_agent
                else None
            )
        )
        task = arguments.get("task") if selected_agent else None
        if not isinstance(task, str) or not task.strip():
            result = {
                "content": [{"type": "text", "text": "A non-empty task is required."}],
                "isError": True,
            }
        else:
            try:
                text = selected_agent(task.strip())
                result = {"content": [{"type": "text", "text": text}], "isError": False}
            except Exception as exc:
                result = {
                    "content": [{"type": "text", "text": str(exc)}],
                    "isError": True,
                }
    else:
        return {
            "jsonrpc": "2.0",
            "id": request_id,
            "error": {"code": -32601, "message": f"Method not found: {method}"},
        }
    return {"jsonrpc": "2.0", "id": request_id, "result": result}


def serve(
    stdin: Any = sys.stdin,
    stdout: Any = sys.stdout,
    run_agent: Callable[[str, threading.Event], str] = run_local_agent,
    tool_name: str = "unsloth_agent",
    tool_description: str | None = None,
    run_read_only_agent: Callable[[str, threading.Event], str] | None = None,
    read_only_tool_name: str | None = None,
    instructions: str | None = None,
) -> None:
    active: dict[object, threading.Event] = {}
    workers: list[threading.Thread] = []
    state_lock = threading.RLock()
    output_lock = threading.Lock()
    shutdown_started = threading.Event()

    def cancel_active() -> None:
        with state_lock:
            pending = list(active.values())
        for cancel_event in pending:
            cancel_event.set()

    def handle_shutdown(_signum: int, _frame: Any) -> None:
        # Claude Code sends SIGINT (possibly repeatedly) to cancel a tool call. Only
        # the first unwinds stdin; later ones must not interrupt process-tree cleanup.
        first_signal = not shutdown_started.is_set()
        shutdown_started.set()
        cancel_active()
        if first_signal:
            raise KeyboardInterrupt

    previous_handlers: dict[int, Any] = {}
    if threading.current_thread() is threading.main_thread():
        for signum in (signal.SIGINT, signal.SIGTERM):
            previous_handlers[signum] = signal.signal(signum, handle_shutdown)

    def send(response: dict | None) -> None:
        if response is None:
            return
        with output_lock:
            stdout.write(json.dumps(response, separators = (",", ":")) + "\n")
            stdout.flush()

    def call_tool(request: dict, request_id: object, cancel_event: threading.Event) -> None:
        try:
            response = _response(
                request,
                run_agent = lambda task: run_agent(task, cancel_event),
                tool_name = tool_name,
                tool_description = tool_description,
                run_read_only_agent = (
                    (lambda task: run_read_only_agent(task, cancel_event))
                    if run_read_only_agent
                    else None
                ),
                read_only_tool_name = read_only_tool_name,
                instructions = instructions,
            )
            if not cancel_event.is_set():
                send(response)
        finally:
            with state_lock:
                if active.get(request_id) is cancel_event:
                    active.pop(request_id, None)

    try:
        for line in stdin:
            try:
                request = json.loads(line)
                if not isinstance(request, dict):
                    response = None
                elif request.get("method") == "notifications/cancelled":
                    request_id = (request.get("params") or {}).get("requestId")
                    with state_lock:
                        cancel_event = active.get(request_id)
                    if cancel_event is not None:
                        cancel_event.set()
                    response = None
                elif request.get("method") == "tools/call" and request.get("id") is not None:
                    request_id = request["id"]
                    cancel_event = threading.Event()
                    with state_lock:
                        active[request_id] = cancel_event
                    worker = threading.Thread(
                        target = call_tool,
                        args = (request, request_id, cancel_event),
                        name = f"unsloth-agent-{request_id}",
                    )
                    workers.append(worker)
                    worker.start()
                    response = None
                else:
                    response = _response(
                        request,
                        tool_name = tool_name,
                        tool_description = tool_description,
                        run_read_only_agent = (
                            (lambda task: run_read_only_agent(task, threading.Event()))
                            if run_read_only_agent
                            else None
                        ),
                        read_only_tool_name = read_only_tool_name,
                        instructions = instructions,
                    )
            except Exception as exc:
                response = {
                    "jsonrpc": "2.0",
                    "id": None,
                    "error": {"code": -32603, "message": str(exc)},
                }
            send(response)
    except KeyboardInterrupt:
        pass
    finally:
        cancel_active()
        for worker in workers:
            if worker.ident is not None:
                worker.join()
        for signum, handler in previous_handlers.items():
            signal.signal(signum, handler)


def main() -> None:
    serve(
        run_read_only_agent = lambda task, cancel_event: run_local_agent(
            task, cancel_event, read_only = True
        ),
        read_only_tool_name = "unsloth_plan_agent",
    )


if __name__ == "__main__":
    main()
