"""exec()-based tool script executor for the Zeo MCP server.

Mirrors the C++ PYTHON_TOOL_HANDLER from python_tool_handler.cpp.
Loads the same Python tool scripts the agent uses and executes them
with an identical namespace (TOOL_ARGS, preamble, kicad/sch/board).
"""

import io
import json
import math
import os
import re
import sys
import threading
from typing import Any, Optional

# Match the 30-second timeout from terminal_frame.cpp (line 329)
TOOL_TIMEOUT_SECONDS = 30

# Guard sys.stdout swaps so concurrent threads don't interleave redirects
_stdout_lock = threading.Lock()


def find_python_dir() -> str:
    """Locate the agent Python scripts directory.

    Mirrors PYTHON_TOOL_HANDLER::FindPythonDir() from python_tool_handler.cpp.
    Search order:
      1. AGENT_PYTHON_DIR env var
      2. Relative to kipy package (dev layout: ../../zeo/agent/tools/python/)
      3. App bundle: Zeo.app/Contents/SharedSupport/agent/python/
    """
    # 1. Environment variable
    env_dir = os.environ.get("AGENT_PYTHON_DIR")
    if env_dir and os.path.isdir(env_dir):
        return env_dir

    # 2. Dev layout: kipy/mcp/tool_runner.py -> kipy -> zeo-python -> code -> zeo
    kipy_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    zeo_python_dir = os.path.dirname(kipy_dir)
    candidate = os.path.join(
        zeo_python_dir, "..", "zeo", "agent", "tools", "python"
    )
    if os.path.isdir(candidate):
        return os.path.realpath(candidate)

    # 3. App bundle (macOS)
    for app_name in ("Zeo.app", "KiCad.app"):
        app_path = f"/Applications/{app_name}/Contents/SharedSupport/agent/python"
        if os.path.isdir(app_path):
            return app_path

    raise FileNotFoundError(
        "Cannot locate agent Python scripts directory. "
        "Set AGENT_PYTHON_DIR or run from the dev workspace."
    )


def extract_last_json(output: str) -> str:
    """Return the last valid JSON object from a possibly-mixed output string.

    Tool scripts may print debug lines before their JSON result.
    Returns the raw output unchanged if no JSON object is found.
    """
    output = output.strip()
    if not output:
        return output
    try:
        json.loads(output)
        return output
    except json.JSONDecodeError:
        for line in reversed(output.split("\n")):
            line = line.strip()
            if line.startswith("{"):
                try:
                    json.loads(line)
                    return line
                except json.JSONDecodeError:
                    continue
    return output


class ToolRunner:
    """Loads and executes agent Python tool scripts.

    Replicates the execution context that the C++ terminal_frame.cpp
    provides: kicad/sch/board objects, TOOL_ARGS dict, preamble
    utilities, and optional bbox utilities for placement tools.
    """

    def __init__(self, kicad, tools: list[dict]):
        """Initialize the runner.

        Args:
            kicad: Connected kipy.KiCad instance.
            tools: List of tool definition dicts from schemas.py.
        """
        self.kicad = kicad
        self.python_dir = find_python_dir()

        # Load shared utility scripts (same as python_tool_handler.cpp constructor)
        self._preamble = self._read_file("common/preamble.py")
        self._bbox = self._read_file("common/bbox.py")

        if not self._preamble:
            print(
                "WARNING: preamble.py not found — tool scripts may fail",
                file=sys.stderr,
            )

        # Build tool registry: name -> {app, script_content, needs_bbox}
        self._tools: dict[str, dict[str, Any]] = {}
        for tool in tools:
            name = tool["name"]
            script_path = tool.get("script")
            if script_path is None:
                continue  # Direct tools (check_status) — no script

            content = self._read_file(script_path)
            if not content:
                print(
                    f"WARNING: Script not found for {name}: {script_path}",
                    file=sys.stderr,
                )
                continue

            self._tools[name] = {
                "app": tool["app"],
                "script": content,
                "needs_bbox": tool.get("needs_bbox", False),
            }

    def _read_file(self, relative_path: str) -> str:
        full_path = os.path.join(self.python_dir, relative_path)
        try:
            with open(full_path) as f:
                return f.read()
        except FileNotFoundError:
            return ""

    def has_tool(self, tool_name: str) -> bool:
        return tool_name in self._tools

    def build_script(self, tool_name: str, tool_args: dict) -> Optional[str]:
        """Build the full executable script for a tool, or None if unknown."""
        entry = self._tools.get(tool_name)
        if entry is None:
            return None
        script = f"import json\nTOOL_ARGS = json.loads({json.dumps(json.dumps(tool_args))})\n"
        script += self._preamble + "\n"
        if entry["needs_bbox"]:
            script += self._bbox + "\n"
        script += entry["script"]
        return script

    def execute(self, tool_name: str, tool_args: dict) -> str:
        """Execute a tool script and return its output.

        Mirrors the execution context from terminal_frame.cpp:490-569.
        """
        entry = self._tools.get(tool_name)
        if entry is None:
            return json.dumps({"status": "error", "message": f"Unknown tool: {tool_name}"})

        app = entry["app"]

        # Connect to the appropriate editor
        # (mirrors terminal_frame.cpp:533-559)
        namespace: dict[str, Any] = {
            "TOOL_ARGS": tool_args,
            "json": json,
            "sys": sys,
            "math": math,
            "os": os,
            "re": re,
            "kicad": self.kicad,
        }

        # Import kipy and Vector2 into namespace
        # (mirrors terminal_frame.cpp:536-537)
        import kipy
        from kipy.geometry import Vector2

        namespace["kipy"] = kipy
        namespace["Vector2"] = Vector2

        try:
            if app == "sch":
                namespace["sch"] = self.kicad.get_schematic()
                if hasattr(namespace["sch"], "refresh_document"):
                    namespace["sch"].refresh_document()
            elif app == "pcb":
                namespace["board"] = self.kicad.get_board()
        except Exception as e:
            editor_name = "Schematic" if app == "sch" else "PCB"
            return json.dumps({
                "status": "error",
                "message": f"{editor_name} editor not open: {e}",
            })

        # Assemble script (same order as GetIPCCommand in python_tool_handler.cpp:590-601)
        script = self._preamble + "\n"
        if entry["needs_bbox"]:
            script += self._bbox + "\n"
        script += entry["script"]

        # Capture stdout (tool scripts print their JSON output)
        # Run in a thread with timeout to match the C++ agent's 30s limit
        # (terminal_frame.cpp:329). Without this, a runaway A* or similar
        # algorithm blocks the entire MCP server indefinitely.
        capture = io.StringIO()
        exec_error: list[BaseException | None] = [None]

        def _run_script():
            # Redirect stdout for this thread only.  We swap sys.stdout under
            # a lock so concurrent tool executions (shouldn't happen, but
            # defensive) don't interleave.  The finally block always restores.
            with _stdout_lock:
                old_stdout = sys.stdout
                sys.stdout = capture
            try:
                exec(script, namespace)  # noqa: S102
            except SystemExit:
                pass  # Scripts use SystemExit for early return
            except Exception as e:
                exec_error[0] = e
            finally:
                with _stdout_lock:
                    sys.stdout = old_stdout

        thread = threading.Thread(target=_run_script, daemon=True)
        thread.start()
        thread.join(timeout=TOOL_TIMEOUT_SECONDS)

        if thread.is_alive():
            # Thread is still running — timed out.
            # Daemon thread will be abandoned (no way to kill a Python thread).
            return json.dumps({
                "status": "error",
                "message": f"Tool execution timed out after {TOOL_TIMEOUT_SECONDS} seconds",
            })

        if exec_error[0] is not None:
            e = exec_error[0]
            return json.dumps({
                "status": "error",
                "message": f"Tool execution failed: {type(e).__name__}: {e}",
            })

        output = capture.getvalue().strip()
        if not output:
            return json.dumps({"status": "success", "message": "Tool completed with no output"})

        return extract_last_json(output)
