"""FreeCADCmd subprocess executor.

Runs bounded CAD operations through FreeCADCmd (FreeCAD's command-line
interface) instead of embedded import or XML-RPC.  This avoids host Python
ABI mismatch because the script runs inside FreeCAD's own Python runtime.

Rules:
- Only recognizes code patterns generated by ``_execute_set_parameter``,
  ``_execute_export_step``, and ``_execute_export_fcstd``.
- Rejects arbitrary code with a clear error.
- Never modifies input FCStd in place.
- Generates bounded scripts from templates; user values pass through JSON,
  not string interpolation.
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import os
import re
import sys
import tempfile
from pathlib import Path
from typing import Any

from freecad_mcp.contracts import ToolExecutionError


# ------------------------------------------------------------------
# Script templates
# ------------------------------------------------------------------

_MODIFY_PARAMETER_SCRIPT = """\
import json
import os
import FreeCAD

input_path = os.environ["FC_OP_INPUT"]
result_path = os.environ["FC_RESULT_PATH"]
output_path = os.environ.get("FC_OP_OUTPUT")

with open(input_path) as f:
    op = json.load(f)

doc_name = op.get("doc_name")
input_fcstd = op.get("input_fcstd")

if input_fcstd:
    doc = FreeCAD.open(input_fcstd)
elif doc_name:
    doc = FreeCAD.getDocument(doc_name)
else:
    doc = FreeCAD.ActiveDocument

if doc is None:
    raise ValueError("Document not found")

obj = doc.getObject(op["object_name"])
if obj is None:
    raise ValueError(f"Object not found: {op['object_name']}")

param_name = op["parameter_name"]
if not hasattr(obj, param_name):
    raise ValueError(f"Parameter not found: {param_name}")

old_value = getattr(obj, param_name)
try:
    if hasattr(old_value, "Value"):
        old_value = old_value.Value
except Exception:
    pass

setattr(obj, param_name, op["new_value"])
doc.recompute()

if output_path:
    doc.saveAs(output_path)

result = {
    "success": True,
    "object_name": obj.Name,
    "parameter_name": param_name,
    "old_value": old_value,
    "new_value": op["new_value"],
    "freecad_version": ".".join(FreeCAD.Version()[:3]),
}

with open(result_path, "w") as f:
    json.dump(result, f)
"""

_EXPORT_STEP_SCRIPT = """\
import json
import os
import FreeCAD
import Part

input_path = os.environ["FC_OP_INPUT"]
result_path = os.environ["FC_RESULT_PATH"]
output_path = os.environ.get("FC_OP_OUTPUT")

with open(input_path) as f:
    op = json.load(f)

doc_name = op.get("doc_name")
input_fcstd = op.get("input_fcstd")

if input_fcstd:
    doc = FreeCAD.open(input_fcstd)
elif doc_name:
    doc = FreeCAD.getDocument(doc_name)
else:
    doc = FreeCAD.ActiveDocument

if doc is None:
    raise ValueError("Document not found")

objects = []
for obj in doc.Objects:
    if hasattr(obj, "Shape"):
        objects.append(obj)

if not objects:
    raise ValueError("No exportable objects found")

if len(objects) == 1:
    shape = objects[0].Shape
else:
    shape = Part.makeCompound([obj.Shape for obj in objects])

shape.exportStep(output_path)

result = {
    "success": True,
    "file_path": output_path,
    "object_count": len(objects),
}

with open(result_path, "w") as f:
    json.dump(result, f)
"""

_EXPORT_FCSTD_SCRIPT = """\
import json
import os
import FreeCAD

input_path = os.environ["FC_OP_INPUT"]
result_path = os.environ["FC_RESULT_PATH"]
output_path = os.environ.get("FC_OP_OUTPUT")

with open(input_path) as f:
    op = json.load(f)

doc_name = op.get("doc_name")
input_fcstd = op.get("input_fcstd")

if input_fcstd:
    doc = FreeCAD.open(input_fcstd)
elif doc_name:
    doc = FreeCAD.getDocument(doc_name)
else:
    doc = FreeCAD.ActiveDocument

if doc is None:
    raise ValueError("Document not found")

doc.saveAs(output_path)

result = {
    "success": True,
    "file_path": output_path,
    "document": doc.Name,
}

with open(result_path, "w") as f:
    json.dump(result, f)
"""

_VERSION_SCRIPT = """\
import json
import os
import FreeCAD

result_path = os.environ["FC_RESULT_PATH"]

version_info = FreeCAD.Version()
result = {
    "version": ".".join(str(v) for v in version_info[:3]),
    "revision": version_info[3] if len(version_info) > 3 else "",
    "python_version": version_info[4] if len(version_info) > 4 else "",
    "gui_available": FreeCAD.GuiUp,
}

with open(result_path, "w") as f:
    json.dump(result, f)
"""


# ------------------------------------------------------------------
# Executor
# ------------------------------------------------------------------

class FreecadCmdExecutor:
    """Execute bounded FreeCAD operations via FreeCADCmd subprocess.

    Args:
        timeout: Maximum seconds to wait for a subprocess to complete.
    """

    def __init__(self, timeout: float = 120.0) -> None:
        self.timeout = timeout
        self._cmd_path = self._resolve_cmd_path()
        self.calls: list[str] = []
        self._last_script_hash: str | None = None
        self._last_exit_code: int | None = None
        self._last_stdout: str = ""
        self._last_stderr: str = ""

    # ------------------------------------------------------------------
    # Path resolution
    # ------------------------------------------------------------------

    @staticmethod
    def _resolve_cmd_path() -> str:
        home_str = os.environ.get("FREECAD_MCP_FREECAD_PATH") or os.environ.get("FREECAD_HOME")
        if not home_str:
            raise ToolExecutionError(
                "FreeCADCmd not found. Set FREECAD_HOME or FREECAD_MCP_FREECAD_PATH."
            )
        home = Path(home_str)

        candidates: list[Path] = []
        if sys.platform == "win32":
            candidates = [
                home / "bin" / "FreeCADCmd.exe",
                home / "bin" / "freecadcmd.exe",
                home / "FreeCADCmd.exe",
            ]
        else:
            candidates = [
                home / "bin" / "FreeCADCmd",
                home / "bin" / "freecadcmd",
                home / "bin" / "FreeCAD",
            ]

        for cand in candidates:
            if cand.is_file():
                return str(cand.resolve())

        raise ToolExecutionError(
            f"FreeCADCmd not found in {home_str}. Looked for: "
            f"{[str(c) for c in candidates]}"
        )

    # ------------------------------------------------------------------
    # Code-pattern parsing
    # ------------------------------------------------------------------

    _GETOBJECT_RE = re.compile(r"doc\.getObject\((['\"])([^'\"]*?)\1\)")
    _GETATTR_RE = re.compile(r"getattr\(obj,\s*(['\"])([^'\"]*?)\1\)")
    _SETATTR_RE = re.compile(
        r"setattr\(obj,\s*(['\"])([^'\"]*?)\1,\s*(.+)\)\s*$", re.MULTILINE
    )
    _EXPORT_STEP_RE = re.compile(r"shape\.exportStep\((['\"])([^'\"]*?)\1\)")
    _SAVEAS_RE = re.compile(r"doc\.saveAs\((['\"])([^'\"]*?)\1\)")
    _OPEN_RE = re.compile(r"FreeCAD\.open\((['\"])([^'\"]*?)\1\)")

    def _parse_operation(self, code: str) -> dict[str, Any] | None:
        """Parse a code string into structured operation parameters."""
        result = self._try_parse_modify_parameter(code)
        if result is not None:
            return result

        result = self._try_parse_export_step(code)
        if result is not None:
            return result

        result = self._try_parse_export_fcstd(code)
        if result is not None:
            return result

        return None

    def _try_parse_modify_parameter(self, code: str) -> dict[str, Any] | None:
        obj_match = self._GETOBJECT_RE.search(code)
        if not obj_match:
            return None

        getattr_match = self._GETATTR_RE.search(code)
        if not getattr_match:
            return None

        setattr_match = self._SETATTR_RE.search(code)
        if not setattr_match:
            return None

        obj_name = obj_match.group(2)
        param_name = getattr_match.group(2)

        try:
            raw_value = setattr_match.group(3).strip()
            new_value = json.loads(raw_value)
        except (ValueError, json.JSONDecodeError):
            return None

        doc_name = self._extract_doc_name(code)
        input_fcstd = self._extract_input_fcstd(code)

        return {
            "operation": "modify_parameter",
            "object_name": obj_name,
            "parameter_name": param_name,
            "new_value": new_value,
            "doc_name": doc_name,
            "input_fcstd": input_fcstd,
        }

    def _try_parse_export_step(self, code: str) -> dict[str, Any] | None:
        match = self._EXPORT_STEP_RE.search(code)
        if not match:
            return None

        file_path = match.group(2)
        doc_name = self._extract_doc_name(code)
        input_fcstd = self._extract_input_fcstd(code)

        return {
            "operation": "export_step",
            "file_path": file_path,
            "doc_name": doc_name,
            "input_fcstd": input_fcstd,
        }

    def _try_parse_export_fcstd(self, code: str) -> dict[str, Any] | None:
        match = self._SAVEAS_RE.search(code)
        if not match:
            return None

        file_path = match.group(2)
        doc_name = self._extract_doc_name(code)
        input_fcstd = self._extract_input_fcstd(code)

        return {
            "operation": "export_fcstd",
            "file_path": file_path,
            "doc_name": doc_name,
            "input_fcstd": input_fcstd,
        }

    def _extract_doc_name(self, code: str) -> str | None:
        m = re.search(r'FreeCAD\.getDocument\((["\'])([^"\']*?)\1\)', code)
        if m:
            return m.group(2)
        if "FreeCAD.ActiveDocument" in code:
            return None
        return None

    def _extract_input_fcstd(self, code: str) -> str | None:
        m = self._OPEN_RE.search(code)
        if m:
            return m.group(2)
        return None

    # ------------------------------------------------------------------
    # Script generation
    # ------------------------------------------------------------------

    def _generate_script(self, op: dict[str, Any]) -> str:
        op_type = op.get("operation")
        if op_type == "modify_parameter":
            return _MODIFY_PARAMETER_SCRIPT
        if op_type == "export_step":
            return _EXPORT_STEP_SCRIPT
        if op_type == "export_fcstd":
            return _EXPORT_FCSTD_SCRIPT
        raise ToolExecutionError(f"Unsupported operation: {op_type}")

    # ------------------------------------------------------------------
    # Execution
    # ------------------------------------------------------------------

    async def execute_async(self, code: str) -> dict[str, Any]:
        """Run a bounded operation via FreeCADCmd subprocess.

        Raises:
            ToolExecutionError: If the code pattern is not recognized or
                the subprocess fails in an unrecoverable way.
        """
        self.calls.append(code)

        op = self._parse_operation(code)
        if op is None:
            raise ToolExecutionError(
                "FreeCADCmd executor only supports bounded operations. "
                "Operation pattern not recognized."
            )

        # Guard: never overwrite input FCStd with output
        if op.get("input_fcstd") and op.get("output_fcstd"):
            if Path(op["input_fcstd"]).resolve() == Path(op["output_fcstd"]).resolve():
                raise ToolExecutionError(
                    "input_fcstd and output_fcstd must not be the same file"
                )

        script_content = self._generate_script(op)
        self._last_script_hash = hashlib.sha256(
            script_content.encode("utf-8")
        ).hexdigest()

        with tempfile.TemporaryDirectory() as tmpdir:
            op_path = Path(tmpdir) / "op.json"
            with open(op_path, "w", encoding="utf-8") as f:
                json.dump(op, f)

            script_path = Path(tmpdir) / "script.py"
            with open(script_path, "w", encoding="utf-8") as f:
                f.write(script_content)

            result_path = Path(tmpdir) / "result.json"

            env = {
                **os.environ,
                "FC_OP_INPUT": str(op_path),
                "FC_RESULT_PATH": str(result_path),
            }
            if op.get("file_path"):
                env["FC_OP_OUTPUT"] = op["file_path"]

            proc = await asyncio.create_subprocess_exec(
                self._cmd_path,
                str(script_path),
                env=env,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )

            try:
                stdout_b, stderr_b = await asyncio.wait_for(
                    proc.communicate(), timeout=self.timeout
                )
            except asyncio.TimeoutError:
                proc.kill()
                await proc.wait()
                raise ToolExecutionError(
                    f"FreeCADCmd timed out after {self.timeout}s"
                )

            self._last_exit_code = proc.returncode or 0
            self._last_stdout = stdout_b.decode("utf-8", errors="replace")
            self._last_stderr = stderr_b.decode("utf-8", errors="replace")

            if proc.returncode != 0:
                error_msg = (
                    self._last_stderr.strip()
                    or self._last_stdout.strip()
                    or f"FreeCADCmd exited {proc.returncode}"
                )
                return {
                    "success": False,
                    "error": error_msg,
                    "exit_code": proc.returncode,
                    "stdout": self._last_stdout,
                    "stderr": self._last_stderr,
                }

            if not result_path.exists():
                return {
                    "success": False,
                    "error": "FreeCADCmd did not produce a result file",
                    "stdout": self._last_stdout,
                    "stderr": self._last_stderr,
                }

            with open(result_path, "r", encoding="utf-8") as f:
                result = json.load(f)

            return {
                "success": result.get("success", True),
                "result": result,
                "stdout": self._last_stdout,
                "stderr": self._last_stderr,
            }

    async def get_version_async(self) -> dict[str, Any]:
        """Query FreeCAD version via FreeCADCmd subprocess."""
        with tempfile.TemporaryDirectory() as tmpdir:
            script_path = Path(tmpdir) / "version.py"
            with open(script_path, "w", encoding="utf-8") as f:
                f.write(_VERSION_SCRIPT)

            result_path = Path(tmpdir) / "result.json"

            proc = await asyncio.create_subprocess_exec(
                self._cmd_path,
                str(script_path),
                env={**os.environ, "FC_RESULT_PATH": str(result_path)},
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )

            try:
                stdout_b, stderr_b = await asyncio.wait_for(
                    proc.communicate(), timeout=self.timeout
                )
            except asyncio.TimeoutError:
                proc.kill()
                await proc.wait()
                return {
                    "version": "unknown",
                    "revision": "",
                    "python_version": "",
                    "gui_available": False,
                }

            if proc.returncode != 0 or not result_path.exists():
                return {
                    "version": "unknown",
                    "revision": "",
                    "python_version": "",
                    "gui_available": False,
                }

            with open(result_path, "r", encoding="utf-8") as f:
                return json.load(f)
