"""
Custom Node Executor for Local DCC Agent
Enables studios to create and execute their own custom nodes locally.

This module:
- Discovers custom nodes from ~/plumber/custom_nodes/*.py
- Imports and validates node classes using the Plumber SDK
- Executes nodes with sandboxing (timeout, resource limits)
- Provides hot reload via file watching
- Sends only metadata to backend (code never leaves local machine)
"""

import asyncio
import importlib.util
import json
import logging
import os
import subprocess
import sys
import tempfile
import threading
import time
import traceback
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Type, Callable
from dataclasses import dataclass, field
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
import hashlib

logger = logging.getLogger(__name__)


# ============================================================================
# Plumber SDK Classes — Identical to backend (plumber_sdk/)
# Copied from the production backend to ensure API parity.
# Custom nodes written for backend work as-is on the local agent.
# ============================================================================


# --- Property System (from plumber_sdk/properties.py) ---

class Property:
    """
    Property definition for automatic UI generation.

    Features:
    - Type validation based on Python type hints
    - UI type specification (slider, dropdown, file picker, etc.)
    - Constraints (min, max, choices, etc.)
    - Default values and validation
    """

    def __init__(
        self,
        default: Any = None,
        ui: str = "auto",
        label: Optional[str] = None,
        tooltip: Optional[str] = None,
        min_value: Optional[float] = None,
        max_value: Optional[float] = None,
        step: Optional[float] = None,
        choices: Optional[List[str]] = None,
        multiline: bool = False,
        file_filter: Optional[str] = None,
        language: Optional[str] = None,
        **kwargs
    ):
        self.default = default
        self.ui_type = ui
        self.label = label
        self.tooltip = tooltip
        self.min_value = min_value
        self.max_value = max_value
        self.step = step
        self.choices = choices
        self.multiline = multiline
        self.file_filter = file_filter
        self.language = language
        self.constraints = kwargs
        self.name = None
        self.type = None

    def get_type_name(self) -> str:
        """Get string representation of the type."""
        if self.type is None:
            return "any"
        if hasattr(self.type, '__name__'):
            return self.type.__name__
        return str(self.type)

    def infer_ui_type(self) -> str:
        """Infer UI type from Python type if set to 'auto'."""
        if self.ui_type != "auto":
            return self.ui_type
        if self.choices:
            return "dropdown"
        type_name = self.get_type_name()
        if type_name == "bool":
            return "checkbox"
        elif type_name in ["int", "float"]:
            if self.min_value is not None and self.max_value is not None:
                return "slider"
            return "number"
        elif type_name == "str":
            if self.file_filter:
                return "file"
            elif self.ui_type == "code":
                return "code"
            elif self.multiline:
                return "textarea"
            elif "color" in (self.name or "").lower():
                return "color"
            return "text"
        elif type_name in ["tuple", "list"] and "color" in (self.name or "").lower():
            return "color"
        return "text"

    def validate(self, value: Any) -> Any:
        """
        Validate and convert value according to type and constraints.

        Returns:
            Validated and converted value

        Raises:
            ValueError: If validation fails
        """
        if value is None:
            return self.default

        # Type conversion
        if self.type is not None:
            try:
                if self.type == bool:
                    if isinstance(value, str):
                        value = value.lower() in ('true', '1', 'yes', 'on')
                    else:
                        value = bool(value)
                elif self.type in [int, float]:
                    # Handle empty strings — use default value
                    if isinstance(value, str) and value.strip() == '':
                        return self.default
                    value = self.type(value)
                elif self.type == str:
                    value = str(value)
                elif hasattr(self.type, '__origin__'):
                    pass  # Generic types like List[str] — pass through
            except (ValueError, TypeError) as e:
                raise ValueError(f"Cannot convert {value} to {self.get_type_name()}: {e}")

        # Constraint validation
        if self.choices:
            if isinstance(value, list):
                for item in value:
                    if item not in self.choices:
                        raise ValueError(f"List item '{item}' not in allowed choices: {self.choices}")
            elif value not in self.choices:
                raise ValueError(f"Value {value} not in allowed choices: {self.choices}")

        if isinstance(value, (int, float)):
            if self.min_value is not None and value < self.min_value:
                raise ValueError(f"Value {value} below minimum {self.min_value}")
            if self.max_value is not None and value > self.max_value:
                raise ValueError(f"Value {value} above maximum {self.max_value}")

        return value

    def get_constraints(self) -> Dict[str, Any]:
        """Get all constraints as dictionary for UI."""
        constraints = {}
        if self.min_value is not None:
            constraints["min"] = self.min_value
        if self.max_value is not None:
            constraints["max"] = self.max_value
        if self.step is not None:
            constraints["step"] = self.step
        if self.choices is not None:
            constraints["choices"] = self.choices
        if self.file_filter is not None:
            constraints["file_filter"] = self.file_filter
        if self.multiline:
            constraints["multiline"] = True
        if self.language is not None:
            constraints["language"] = self.language
        constraints.update(self.constraints)
        return constraints


# --- Port System (from plumber_sdk/ports.py) ---

class Port:
    """Base class for input and output ports."""

    def __init__(
        self,
        type: Any = Any,
        label: Optional[str] = None,
        description: Optional[str] = None
    ):
        self.type = type
        self.label = label
        self.description = description
        self.name = None  # Set by metaclass

    def get_type_name(self) -> str:
        """Get string representation of the port type."""
        if self.type == Any:
            return "any"
        elif hasattr(self.type, '__name__'):
            return self.type.__name__
        return str(self.type)


class Input(Port):
    """Input port definition — data flowing INTO a node."""

    def __init__(
        self,
        type: Any = Any,
        required: bool = True,
        label: Optional[str] = None,
        description: Optional[str] = None
    ):
        super().__init__(type, label, description)
        self.required = required

    def validate(self, value: Any) -> Any:
        """Validate input value against expected type."""
        if value is None and self.required:
            raise ValueError(f"Required input '{self.name}' cannot be None")
        return value


class Output(Port):
    """Output port definition — data flowing OUT OF a node."""

    def __init__(
        self,
        type: Any = Any,
        label: Optional[str] = None,
        description: Optional[str] = None
    ):
        super().__init__(type, label, description)

    def validate(self, value: Any) -> Any:
        """Validate output value against expected type."""
        return value


# Specialized port types
class FileInput(Input):
    """Input port for file paths."""
    def __init__(self, file_types: Optional[List[str]] = None,
                 required: bool = True, label: Optional[str] = None,
                 description: Optional[str] = None):
        super().__init__(str, required, label, description)
        self.file_types = file_types or []

    def validate(self, value: Any) -> Any:
        value = super().validate(value)
        if value and self.file_types:
            file_path = Path(value)
            if file_path.suffix.lower() not in [ext.lower() for ext in self.file_types]:
                raise ValueError(f"File '{self.name}' must be one of: {self.file_types}")
        return value


class ImageInput(Input):
    """Input port for images."""
    def __init__(self, required: bool = True, label: Optional[str] = None,
                 description: Optional[str] = None):
        super().__init__(type="image", required=required, label=label, description=description)


class ArrayInput(Input):
    """Input port for numerical arrays."""
    def __init__(self, shape: Optional[tuple] = None, dtype: Optional[str] = None,
                 required: bool = True, label: Optional[str] = None,
                 description: Optional[str] = None):
        super().__init__(type="array", required=required, label=label, description=description)
        self.shape = shape
        self.dtype = dtype


class GeometryInput(Input):
    """Input port for 3D geometry."""
    def __init__(self, formats: Optional[List[str]] = None,
                 required: bool = True, label: Optional[str] = None,
                 description: Optional[str] = None):
        super().__init__(type="geometry", required=required, label=label, description=description)
        self.formats = formats or ['.fbx', '.obj', '.ma', '.mb', '.hip', '.blend']


class FileOutput(Output):
    """Output port for file paths."""
    def __init__(self, file_type: Optional[str] = None,
                 label: Optional[str] = None, description: Optional[str] = None):
        super().__init__(str, label, description)
        self.file_type = file_type


class ImageOutput(Output):
    """Output port for images."""
    def __init__(self, format: str = "PNG",
                 label: Optional[str] = None, description: Optional[str] = None):
        super().__init__(type="image", label=label, description=description)
        self.format = format


class ArrayOutput(Output):
    """Output port for numerical arrays."""
    def __init__(self, shape: Optional[tuple] = None, dtype: Optional[str] = None,
                 label: Optional[str] = None, description: Optional[str] = None):
        super().__init__(type="array", label=label, description=description)
        self.shape = shape
        self.dtype = dtype


# --- Execution Context (from plumber_sdk/context.py) ---

class ExecutionContext:
    """
    Execution context passed to nodes during workflow execution.

    Provides: logging, progress reporting, workspace paths,
    inter-node communication, and workflow metadata.
    """

    def __init__(
        self,
        workflow_id: str = "",
        node_id: str = "",
        workflow_data: Optional[Dict[str, Any]] = None,
        logger: Optional[logging.Logger] = None,
        workspace_path: Optional[Path] = None,
        user_id: str = "",
        progress_callback: Optional[Callable] = None,
        log_callback: Optional[Callable] = None,
        dcc_servers: Optional[Dict[str, Any]] = None,
        dcc_executor: Optional[Any] = None
    ):
        self.workflow_id = workflow_id
        self.node_id = node_id
        self.workflow_data = workflow_data or {}
        self.logger = logger or logging.getLogger(f"plumber.{workflow_id}.{node_id}")
        self.workspace_path = workspace_path or Path.cwd()
        self.user_id = user_id
        self._progress_callback = progress_callback
        self._log_callback = log_callback
        self._node_results: Dict[str, Any] = {}
        self.variables: Dict[str, Any] = {}
        self.logs: List[str] = []
        self._dcc_servers = dcc_servers or {}
        self._dcc_executor = dcc_executor

    # --- Logging ---

    def log(self, message: str, level: str = "info"):
        """Log a message (convenience method that also captures to logs list)."""
        self.logs.append(f"[{level.upper()}] {message}")
        getattr(self.logger, level, self.logger.info)(f"[{self.node_id}] {message}")
        if self._log_callback:
            try:
                import asyncio
                if asyncio.iscoroutinefunction(self._log_callback):
                    try:
                        loop = asyncio.get_event_loop()
                        if loop.is_running():
                            asyncio.create_task(self._log_callback(self.node_id, level, message))
                        else:
                            loop.run_until_complete(self._log_callback(self.node_id, level, message))
                    except RuntimeError:
                        asyncio.run(self._log_callback(self.node_id, level, message))
                else:
                    self._log_callback(self.node_id, level, message)
            except Exception as e:
                self.logger.error(f"Log callback failed: {e}")

    def log_info(self, message: str) -> None:
        """Log info message."""
        self.log(message, "info")

    def log_warning(self, message: str) -> None:
        """Log warning message."""
        self.log(message, "warning")

    def log_error(self, message: str) -> None:
        """Log error message."""
        self.log(message, "error")

    # --- Progress ---

    def report_progress(self, progress: float, message: Optional[str] = None) -> None:
        """Report node execution progress (0.0 to 1.0)."""
        if self._progress_callback:
            try:
                import asyncio
                if asyncio.iscoroutinefunction(self._progress_callback):
                    try:
                        loop = asyncio.get_event_loop()
                        if loop.is_running():
                            asyncio.create_task(self._progress_callback(self.node_id, progress, message))
                        else:
                            loop.run_until_complete(self._progress_callback(self.node_id, progress, message))
                    except RuntimeError:
                        asyncio.run(self._progress_callback(self.node_id, progress, message))
                else:
                    self._progress_callback(self.node_id, progress, message)
            except Exception as e:
                self.logger.error(f"Progress callback failed: {e}")
        if message:
            self.log_info(f"Progress: {progress:.1%} - {message}")

    def set_progress_callback(self, callback) -> None:
        """Set callback for progress updates."""
        self._progress_callback = callback

    def set_log_callback(self, callback) -> None:
        """Set callback for log messages."""
        self._log_callback = callback

    # --- Inter-node communication ---

    def get_node_result(self, node_id: str) -> Any:
        """Get the result from a previously executed node."""
        return self._node_results.get(node_id)

    def set_node_result(self, node_id: str, result: Any) -> None:
        """Set the result for a node (used by execution engine)."""
        self._node_results[node_id] = result

    # --- Workspace ---

    def get_workspace_path(self, *path_parts: str) -> Path:
        """Get a path within the workspace."""
        return self.workspace_path.joinpath(*path_parts).resolve()

    def ensure_workspace_dir(self, *path_parts: str) -> Path:
        """Ensure a directory exists within the workspace."""
        dir_path = self.get_workspace_path(*path_parts)
        dir_path.mkdir(parents=True, exist_ok=True)
        return dir_path

    # --- Workflow introspection ---

    def get_connected_input_nodes(self) -> List[str]:
        """Get list of node IDs that provide input to current node."""
        connections = self.workflow_data.get("connections", [])
        return [conn.get("from") for conn in connections if conn.get("to") == self.node_id]

    def get_connected_output_nodes(self) -> List[str]:
        """Get list of node IDs that receive output from current node."""
        connections = self.workflow_data.get("connections", [])
        return [conn.get("to") for conn in connections if conn.get("from") == self.node_id]

    def get_workflow_metadata(self) -> Dict[str, Any]:
        """Get workflow metadata."""
        return self.workflow_data.get("metadata", {})

    def get_timestamp(self) -> str:
        """Get current timestamp as ISO string."""
        return datetime.now().isoformat()

    # --- DCC Execution Bridge ---

    def execute_blender(self, script: str, timeout: int = 60, blend_file: str = None) -> Dict[str, Any]:
        """
        Execute a Python script in the running Blender instance.

        Uses the agent's active Blender server (GUI preferred, headless fallback).
        The script should set `result = {...}` to return structured data.
        blend_file: optional .blend file path to open in subprocess mode.

        Returns dict with 'success', 'result', 'output', 'error' keys.
        """
        return self._execute_dcc("blender", script, timeout, blend_file=blend_file)

    def execute_maya(self, script: str, timeout: int = 60) -> Dict[str, Any]:
        """Execute a Python script in the running Maya instance."""
        return self._execute_dcc("maya", script, timeout)

    def execute_houdini(self, script: str, timeout: int = 60) -> Dict[str, Any]:
        """Execute a Python script in the running Houdini instance."""
        return self._execute_dcc("houdini", script, timeout)

    def _execute_dcc(self, dcc_name: str, script: str, timeout: int, blend_file: str = None) -> Dict[str, Any]:
        """Internal: execute script on a DCC server.

        Fallback chain: GUI server → headless server → subprocess.
        """
        # ── PRIORITY 1 & 2: Try GUI server, then headless ──
        gui_key = f"{dcc_name}_gui"
        headless_key = dcc_name

        # Log available servers for diagnostics
        available = []
        for key in [gui_key, headless_key]:
            s = self._dcc_servers.get(key)
            if s:
                alive = hasattr(s, 'is_alive') and s.is_alive()
                available.append(f"{key}={'alive' if alive else 'dead'}")
        self.log_info(f"DCC servers: [{', '.join(available) or 'none wired'}]")

        server = None
        for key in [gui_key, headless_key]:
            s = self._dcc_servers.get(key)
            if s and hasattr(s, 'is_alive') and s.is_alive():
                server = s
                self.log_info(f"Using {key} server for execution")
                break

        if server:
            try:
                result = server.execute_command(script, "custom_node", timeout=timeout)
                self.log_info(f"DCC result: success={result.get('success')}, has_result={result.get('result') is not None}")
                return result
            except Exception as e:
                self.log_warning(f"{dcc_name} server execution failed: {e}, falling back to subprocess...")

        # ── PRIORITY 3: Subprocess fallback ──
        self.log_info(f"No active {dcc_name} server, trying subprocess fallback...")
        return self._execute_dcc_subprocess(dcc_name, script, timeout, blend_file=blend_file)

    def _execute_dcc_subprocess(self, dcc_name: str, script: str, timeout: int, blend_file: str = None) -> Dict[str, Any]:
        """Fallback: run DCC script as a subprocess (slower but always works)."""
        # Find the DCC executable via discovery
        if not self._dcc_executor or not hasattr(self._dcc_executor, 'discovery'):
            return {'success': False, 'result': None, 'output': '',
                    'error': f"No DCC executor available for {dcc_name} subprocess fallback"}

        exe_path = self._dcc_executor.discovery.get_executable_path(dcc_name)
        if not exe_path:
            return {'success': False, 'result': None, 'output': '',
                    'error': f"{dcc_name} executable not found by discovery"}

        self.log_info(f"Subprocess fallback: {dcc_name} at {exe_path}")

        # Wrap the script to capture `result` variable as JSON on stdout
        wrapper = self._build_subprocess_wrapper(dcc_name, script)

        # Write script to temp file
        try:
            tmp_dir = tempfile.mkdtemp(prefix=f"plumber_{dcc_name}_")
            script_path = os.path.join(tmp_dir, f"{dcc_name}_custom_node.py")
            with open(script_path, 'w', encoding='utf-8') as f:
                f.write(wrapper)
        except Exception as e:
            return {'success': False, 'result': None, 'output': '',
                    'error': f"Failed to write temp script: {e}"}

        # Build command
        if dcc_name == 'blender':
            cmd = [exe_path, "--background"]
            if blend_file:
                # Open .blend file before running script
                self.log_info(f"Subprocess will open: {blend_file}")
                cmd.append(blend_file)
            cmd.extend(["--python", script_path])
        elif dcc_name == 'maya':
            # Use mayapy for batch execution
            python_path = self._dcc_executor.discovery.get_executable_path(dcc_name, 'python')
            cmd = [python_path or exe_path, script_path]
        elif dcc_name == 'houdini':
            python_path = self._dcc_executor.discovery.get_executable_path(dcc_name, 'python')
            cmd = [python_path or exe_path, script_path]
        else:
            return {'success': False, 'result': None, 'output': '',
                    'error': f"Unknown DCC: {dcc_name}"}

        self.log_info(f"Subprocess command: {' '.join(cmd)}")

        try:
            process = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                cwd=tmp_dir
            )

            try:
                stdout, stderr = process.communicate(timeout=timeout)
            except subprocess.TimeoutExpired:
                process.kill()
                return {'success': False, 'result': None, 'output': '',
                        'error': f"{dcc_name} subprocess timed out after {timeout}s"}

            self.log_info(f"Subprocess exit code: {process.returncode}")
            if stderr:
                # Filter Blender noise — only log actual errors
                err_lines = [l for l in stderr.strip().split('\n')
                             if l.strip() and not l.startswith('Blender ') and 'Read prefs:' not in l]
                if err_lines:
                    self.log_warning(f"Subprocess stderr: {'; '.join(err_lines[:5])}")

            # Parse the JSON result from the __PLUMBER_RESULT__ marker
            result_data = None
            output_lines = []
            for line in stdout.split('\n'):
                if line.startswith('__PLUMBER_RESULT__:'):
                    try:
                        result_data = json.loads(line[len('__PLUMBER_RESULT__:'):])
                    except json.JSONDecodeError:
                        pass
                else:
                    output_lines.append(line)

            console_output = '\n'.join(output_lines).strip()

            if process.returncode != 0 and result_data is None:
                return {'success': False, 'result': None, 'output': console_output,
                        'error': f"{dcc_name} process exited with code {process.returncode}"}

            return {
                'success': True,
                'result': result_data,
                'output': console_output,
                'error': None
            }

        except FileNotFoundError:
            return {'success': False, 'result': None, 'output': '',
                    'error': f"{dcc_name} executable not found at: {cmd[0]}"}
        except Exception as e:
            return {'success': False, 'result': None, 'output': '',
                    'error': f"{dcc_name} subprocess error: {str(e)}"}

    def _build_subprocess_wrapper(self, dcc_name: str, script: str) -> str:
        """Wrap a custom node script so `result` is captured as JSON on stdout."""
        if dcc_name == 'blender':
            return f'''import bpy
import json
import sys

# ── Custom node script ──
{script}

# ── Capture result ──
if 'result' in dir():
    try:
        print("__PLUMBER_RESULT__:" + json.dumps(result))
    except (TypeError, ValueError):
        print("__PLUMBER_RESULT__:" + json.dumps(str(result)))
else:
    print("__PLUMBER_RESULT__:" + json.dumps(None))
'''
        elif dcc_name == 'maya':
            return f'''import maya.standalone
maya.standalone.initialize()
import maya.cmds as cmds
import json
import sys

# ── Custom node script ──
{script}

# ── Capture result ──
if 'result' in dir():
    try:
        print("__PLUMBER_RESULT__:" + json.dumps(result))
    except (TypeError, ValueError):
        print("__PLUMBER_RESULT__:" + json.dumps(str(result)))
else:
    print("__PLUMBER_RESULT__:" + json.dumps(None))

maya.standalone.uninitialize()
'''
        elif dcc_name == 'houdini':
            return f'''import hou
import json
import sys

# ── Custom node script ──
{script}

# ── Capture result ──
if 'result' in dir():
    try:
        print("__PLUMBER_RESULT__:" + json.dumps(result))
    except (TypeError, ValueError):
        print("__PLUMBER_RESULT__:" + json.dumps(str(result)))
else:
    print("__PLUMBER_RESULT__:" + json.dumps(None))
'''
        else:
            return script


# --- Node Base Class (from plumber_sdk/base_node.py) ---

class NodeMeta(type):
    """Metaclass to automatically discover properties and ports from class annotations."""

    def __new__(mcs, name, bases, namespace, **kwargs):
        cls = super().__new__(mcs, name, bases, namespace, **kwargs)

        cls._properties = {}
        cls._inputs = {}
        cls._outputs = {}

        # Inherit from parent classes
        for base in bases:
            if hasattr(base, '_properties'):
                cls._properties.update(base._properties)
            if hasattr(base, '_inputs'):
                cls._inputs.update(base._inputs)
            if hasattr(base, '_outputs'):
                cls._outputs.update(base._outputs)

        if hasattr(cls, '__annotations__'):
            for attr_name, attr_type in cls.__annotations__.items():
                attr_value = getattr(cls, attr_name, None)

                if isinstance(attr_value, Property):
                    attr_value.name = attr_name
                    attr_value.type = attr_type
                    cls._properties[attr_name] = attr_value
                elif isinstance(attr_value, Input):
                    attr_value.name = attr_name
                    attr_value.type = attr_type
                    cls._inputs[attr_name] = attr_value
                elif isinstance(attr_value, Output):
                    attr_value.name = attr_name
                    attr_value.type = attr_type
                    cls._outputs[attr_name] = attr_value
                elif not attr_name.startswith('_'):
                    # Bare type annotation — create a default property
                    prop = Property()
                    prop.name = attr_name
                    prop.type = attr_type
                    cls._properties[attr_name] = prop

        return cls


class Node(metaclass=NodeMeta):
    """
    Base class for all Plumber SDK nodes.

    Features:
    - Automatic UI generation from type hints
    - Property validation and default values
    - Input/Output port management with validation
    - execute() returns dict of {port_name: value}
    """

    # Override these in subclasses
    _name: str = ""                 # Display name (empty = use class name)
    _category: str = "Custom"
    _icon: str = "Code"
    _description: str = "Custom node"
    _color: str = "#6366f1"  # Indigo

    def __init__(self, **kwargs):
        """Initialize node with property values."""
        self.id = kwargs.get('id', None)
        self.properties = {}
        self.input_values = {}
        self.output_values = {}

        # Initialize properties with defaults or provided values
        for prop_name, prop_def in self._properties.items():
            value = kwargs.get(prop_name, prop_def.default)
            self.properties[prop_name] = prop_def.validate(value)
            setattr(self, prop_name, self.properties[prop_name])

    def execute(self, context: ExecutionContext) -> dict:
        """
        Execute node logic. Override in subclasses.

        Returns:
            dict: Output values keyed by port name, e.g. {"result": 42}
        """
        raise NotImplementedError("Subclasses must implement execute()")

    def get_property(self, name: str) -> Any:
        """Get property value."""
        return self.properties.get(name)

    def set_property(self, name: str, value: Any) -> None:
        """Set property value with validation."""
        if name in self._properties:
            validated_value = self._properties[name].validate(value)
            self.properties[name] = validated_value
            setattr(self, name, validated_value)
        else:
            raise ValueError(f"Property '{name}' not found on node")

    def get_input(self, name: str) -> Any:
        """Get input port value."""
        return self.input_values.get(name)

    def set_input(self, name: str, value: Any) -> None:
        """Set input port value (validates port exists)."""
        if name in self._inputs:
            self.input_values[name] = value
        else:
            # Accept unknown port names with warning (backend may send data under
            # connection port names that don't exactly match declared _inputs)
            logger.warning(f"Input '{name}' not declared on node, setting anyway")
            self.input_values[name] = value

    def set_output(self, name: str, value: Any) -> None:
        """Set output port value (validates port exists)."""
        if name in self._outputs:
            self.output_values[name] = value
        else:
            raise ValueError(f"Output '{name}' not found on node")

    def get_output(self, name: str) -> Any:
        """Get output port value."""
        return self.output_values.get(name)

    def generate_ui_spec(self) -> Dict[str, Any]:
        """Generate UI specification for PropertyPanel."""
        ui_spec = {
            "node_type": self.__class__.__name__,
            "properties": [],
            "inputs": [],
            "outputs": [],
            "inputs_dict": {},
            "outputs_dict": {}
        }

        for prop_name, prop_def in self._properties.items():
            prop_spec = {
                "name": prop_name,
                "type": prop_def.get_type_name(),
                "ui_type": prop_def.infer_ui_type(),
                "default": prop_def.default,
                "current_value": self.get_property(prop_name),
                "label": prop_def.label or prop_name.replace('_', ' ').title(),
                "tooltip": prop_def.tooltip,
                "constraints": prop_def.get_constraints()
            }
            ui_spec["properties"].append(prop_spec)

        for input_name, input_def in self._inputs.items():
            input_spec = {
                "name": input_name,
                "type": input_def.get_type_name(),
                "required": input_def.required,
                "label": input_def.label or input_name.replace('_', ' ').title(),
                "description": input_def.description or ""
            }
            ui_spec["inputs"].append(input_spec)
            ui_spec["inputs_dict"][input_name] = input_spec

        for output_name, output_def in self._outputs.items():
            output_spec = {
                "name": output_name,
                "type": output_def.get_type_name(),
                "label": output_def.label or output_name.replace('_', ' ').title(),
                "description": output_def.description or ""
            }
            ui_spec["outputs"].append(output_spec)
            ui_spec["outputs_dict"][output_name] = output_spec

        return ui_spec

    def validate(self) -> List[str]:
        """Validate node configuration. Returns list of errors (empty if valid)."""
        errors = []
        for prop_name, prop_def in self._properties.items():
            try:
                prop_def.validate(self.get_property(prop_name))
            except ValueError as e:
                errors.append(f"Property '{prop_name}': {str(e)}")
        for input_name, input_def in self._inputs.items():
            if input_def.required and input_name not in self.input_values:
                errors.append(f"Required input '{input_name}' is not connected")
        return errors

    def to_dict(self) -> Dict[str, Any]:
        """Serialize node to dictionary."""
        return {
            "id": self.id,
            "type": self.__class__.__name__,
            "properties": self.properties,
            "ui_spec": self.generate_ui_spec()
        }

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'Node':
        """Create node instance from dictionary."""
        return cls(id=data["id"], **data.get("properties", {}))

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(id={self.id}, properties={self.properties})"


# ============================================================================
# Custom Node Discovery and Execution
# ============================================================================

@dataclass
class CustomNodeInfo:
    """Information about a discovered custom node."""
    name: str
    module_path: str
    file_path: str
    file_hash: str
    category: str
    icon: str
    description: str
    color: str
    properties: Dict[str, Dict]
    inputs: Dict[str, Dict]
    outputs: Dict[str, Dict]
    last_modified: float
    node_class: Optional[Type] = None
    load_error: Optional[str] = None


class CustomNodeExecutor:
    """
    Discovers, loads, and executes custom nodes from local filesystem.

    Custom nodes are loaded from:
    - ~/plumber/custom_nodes/*.py (primary location)
    - User-configured additional directories

    Features:
    - Hot reload on file changes
    - Sandboxed execution with timeout
    - Metadata extraction for backend registration
    """

    DEFAULT_NODES_DIR = Path.home() / "plumber" / "custom_nodes"

    def __init__(
        self,
        nodes_dirs: Optional[List[Path]] = None,
        enable_hot_reload: bool = True,
        execution_timeout: int = 900,  # 15 minutes — DCC exports can take 2+ min per object
        max_workers: int = 4
    ):
        self.nodes_dirs = nodes_dirs or [self.DEFAULT_NODES_DIR]
        self.enable_hot_reload = enable_hot_reload
        self.execution_timeout = execution_timeout
        self.max_workers = max_workers

        self.discovered_nodes: Dict[str, CustomNodeInfo] = {}
        self._file_watchers: List[threading.Thread] = []
        self._stop_watching = threading.Event()
        self._executor = ThreadPoolExecutor(max_workers=max_workers)
        self._lock = threading.Lock()
        self._on_nodes_changed: Optional[Callable] = None
        self._dcc_servers: Dict[str, Any] = {}
        self._dcc_executor = None  # Reference for subprocess fallback

        # Ensure default directory exists
        self.DEFAULT_NODES_DIR.mkdir(parents=True, exist_ok=True)

        logger.info(f"CustomNodeExecutor initialized")
        logger.info(f"Custom nodes directory: {self.DEFAULT_NODES_DIR}")

    def set_dcc_servers(self, dcc_executor) -> None:
        """Wire up DCC servers from the agent's DCC executor for custom node access."""
        self._dcc_executor = dcc_executor  # Keep reference for subprocess fallback
        servers = {}
        if hasattr(dcc_executor, 'blender_gui_server') and dcc_executor.blender_gui_server:
            servers['blender_gui'] = dcc_executor.blender_gui_server
        if hasattr(dcc_executor, 'blender_server') and dcc_executor.blender_server:
            servers['blender'] = dcc_executor.blender_server
        if hasattr(dcc_executor, 'maya_gui_server') and dcc_executor.maya_gui_server:
            servers['maya_gui'] = dcc_executor.maya_gui_server
        if hasattr(dcc_executor, 'maya_server') and dcc_executor.maya_server:
            servers['maya'] = dcc_executor.maya_server
        if hasattr(dcc_executor, 'houdini_gui_server') and dcc_executor.houdini_gui_server:
            servers['houdini_gui'] = dcc_executor.houdini_gui_server
        if hasattr(dcc_executor, 'houdini_server') and dcc_executor.houdini_server:
            servers['houdini'] = dcc_executor.houdini_server
        self._dcc_servers = servers
        logger.info(f"DCC servers wired: {list(servers.keys())}")

    def start(self, on_nodes_changed: Optional[Callable] = None):
        """Start the executor and begin watching for file changes."""
        logger.info("Starting CustomNodeExecutor...")
        self._on_nodes_changed = on_nodes_changed

        # Initial discovery
        self.discover_nodes()

        # Start file watchers
        if self.enable_hot_reload:
            self._start_file_watchers()

        logger.info(f"CustomNodeExecutor started with {len(self.discovered_nodes)} nodes")

    def stop(self):
        """Stop the executor and file watchers."""
        logger.info("Stopping CustomNodeExecutor...")
        self._stop_watching.set()

        for watcher in self._file_watchers:
            watcher.join(timeout=2)

        self._executor.shutdown(wait=False)
        logger.info("CustomNodeExecutor stopped")

    def discover_nodes(self) -> Dict[str, CustomNodeInfo]:
        """Discover all custom nodes from configured directories."""
        with self._lock:
            self.discovered_nodes.clear()

            for nodes_dir in self.nodes_dirs:
                if not nodes_dir.exists():
                    logger.warning(f"Custom nodes directory does not exist: {nodes_dir}")
                    continue

                for py_file in nodes_dir.glob("*.py"):
                    if py_file.name.startswith("_"):
                        continue  # Skip __init__.py and private files

                    try:
                        node_infos = self._load_nodes_from_file(py_file)
                        for node_info in node_infos:
                            self.discovered_nodes[node_info.name] = node_info
                            if node_info.load_error:
                                logger.error(f"Error loading {node_info.name}: {node_info.load_error}")
                            else:
                                logger.info(f"Discovered custom node: {node_info.name}")
                    except Exception as e:
                        logger.error(f"Failed to load nodes from {py_file}: {e}")
                        logger.debug(traceback.format_exc())

            return self.discovered_nodes

    def _load_nodes_from_file(self, file_path: Path) -> List[CustomNodeInfo]:
        """Load node classes from a Python file."""
        nodes = []

        # Calculate file hash for change detection
        file_content = file_path.read_bytes()
        file_hash = hashlib.sha256(file_content).hexdigest()[:16]

        # Create a unique module name
        module_name = f"plumber_custom_nodes.{file_path.stem}_{file_hash}"

        # Load the module
        spec = importlib.util.spec_from_file_location(module_name, file_path)
        if spec is None or spec.loader is None:
            raise ImportError(f"Cannot load module from {file_path}")

        module = importlib.util.module_from_spec(spec)

        # Inject SDK classes into module namespace
        module.__dict__['Node'] = Node
        module.__dict__['Property'] = Property
        module.__dict__['Input'] = Input
        module.__dict__['Output'] = Output
        module.__dict__['ExecutionContext'] = ExecutionContext

        # Execute the module
        try:
            spec.loader.exec_module(module)
        except Exception as e:
            logger.error(f"Error executing module {file_path}: {e}")
            # Return a node info with error
            return [CustomNodeInfo(
                name=file_path.stem,
                module_path=module_name,
                file_path=str(file_path),
                file_hash=file_hash,
                category="Error",
                icon="AlertTriangle",
                description=f"Failed to load: {e}",
                color="#ef4444",
                properties={},
                inputs={},
                outputs={},
                last_modified=file_path.stat().st_mtime,
                node_class=None,
                load_error=str(e)
            )]

        # Find all Node subclasses in the module
        for attr_name in dir(module):
            attr = getattr(module, attr_name)

            if (isinstance(attr, type) and
                issubclass(attr, Node) and
                attr is not Node and
                not attr_name.startswith('_')):

                try:
                    node_info = self._extract_node_info(attr, file_path, file_hash, module_name)
                    nodes.append(node_info)
                except Exception as e:
                    logger.error(f"Failed to extract info from {attr_name}: {e}")

        return nodes

    def _extract_node_info(
        self,
        node_class: Type,
        file_path: Path,
        file_hash: str,
        module_path: str
    ) -> CustomNodeInfo:
        """Extract metadata from a node class."""

        # Extract properties — use same format as backend SDK generate_ui_spec()
        properties = {}
        for prop_name, prop_def in getattr(node_class, '_properties', {}).items():
            properties[prop_name] = {
                'name': prop_name,
                'type': prop_def.get_type_name(),
                'ui_type': prop_def.infer_ui_type(),
                'default': prop_def.default,
                'label': prop_def.label or prop_name.replace('_', ' ').title(),
                'tooltip': prop_def.tooltip,
                'constraints': prop_def.get_constraints(),
            }

        # Extract inputs
        inputs = {}
        for input_name, input_def in getattr(node_class, '_inputs', {}).items():
            inputs[input_name] = {
                'name': input_name,
                'type': input_def.get_type_name(),
                'required': input_def.required,
                'label': input_def.label or input_name.replace('_', ' ').title(),
                'description': input_def.description,
            }

        # Extract outputs
        outputs = {}
        for output_name, output_def in getattr(node_class, '_outputs', {}).items():
            outputs[output_name] = {
                'name': output_name,
                'type': output_def.get_type_name(),
                'label': output_def.label or output_name.replace('_', ' ').title(),
                'description': output_def.description,
            }

        return CustomNodeInfo(
            name=getattr(node_class, '_name', None) or node_class.__name__,
            module_path=module_path,
            file_path=str(file_path),
            file_hash=file_hash,
            category=getattr(node_class, '_category', 'Custom'),
            icon=getattr(node_class, '_icon', 'Code'),
            description=getattr(node_class, '_description', node_class.__doc__ or 'Custom node'),
            color=getattr(node_class, '_color', '#6366f1'),
            properties=properties,
            inputs=inputs,
            outputs=outputs,
            last_modified=file_path.stat().st_mtime if Path(file_path).exists() else 0,
            node_class=node_class,
            load_error=None
        )

    def get_node_metadata(self) -> List[Dict[str, Any]]:
        """
        Get metadata for all discovered nodes.
        This is sent to the backend for registration (NO code included).
        """
        metadata = []

        with self._lock:
            for name, info in self.discovered_nodes.items():
                if info.load_error:
                    continue  # Skip nodes that failed to load

                metadata.append({
                    'node_type': f"custom:{name}",
                    'name': name,
                    'category': info.category,
                    'icon': info.icon,
                    'description': info.description,
                    'color': info.color,
                    'properties': list(info.properties.values()),
                    'inputs': list(info.inputs.values()),
                    'outputs': list(info.outputs.values()),
                    'is_custom': True,
                    'local_only': True,  # Indicates this node runs on local agent
                })

        return metadata

    def get_node_list(self) -> List[str]:
        """Get list of available custom node names."""
        with self._lock:
            return [name for name, info in self.discovered_nodes.items()
                    if not info.load_error]

    async def execute_node(
        self,
        node_type: str,
        node_id: str,
        properties: Dict[str, Any],
        inputs: Dict[str, Any],
        context_data: Optional[Dict[str, Any]] = None,
        progress_callback: Optional[Callable] = None
    ) -> Dict[str, Any]:
        """
        Execute a custom node.

        Args:
            node_type: Node type (e.g., "custom:MyNode" or just "MyNode")
            node_id: Unique node instance ID
            properties: Property values
            inputs: Input values from connected nodes
            context_data: Additional context (workflow_id, user_id, etc.)
            progress_callback: Callback for progress updates

        Returns:
            Dict with 'success', 'outputs', 'logs', 'error', 'execution_time'
        """
        start_time = time.time()

        # Remove "custom:" prefix if present
        if node_type.startswith("custom:"):
            node_type = node_type[7:]

        # Find the node
        with self._lock:
            node_info = self.discovered_nodes.get(node_type)

        if not node_info:
            return {
                'success': False,
                'outputs': {},
                'logs': [],
                'error': f"Custom node '{node_type}' not found",
                'execution_time': time.time() - start_time
            }

        if node_info.load_error:
            return {
                'success': False,
                'outputs': {},
                'logs': [],
                'error': f"Node failed to load: {node_info.load_error}",
                'execution_time': time.time() - start_time
            }

        if node_info.node_class is None:
            return {
                'success': False,
                'outputs': {},
                'logs': [],
                'error': f"Node class not available",
                'execution_time': time.time() - start_time
            }

        logger.info(f"Executing custom node: {node_type} (id: {node_id})")

        # Execute with timeout
        try:
            result = await asyncio.wait_for(
                self._execute_node_async(
                    node_info,
                    node_id,
                    properties,
                    inputs,
                    context_data or {},
                    progress_callback
                ),
                timeout=self.execution_timeout
            )
            result['execution_time'] = time.time() - start_time
            logger.info(f"Custom node {node_type} completed in {result['execution_time']:.2f}s")
            return result

        except asyncio.TimeoutError:
            return {
                'success': False,
                'outputs': {},
                'logs': [],
                'error': f"Execution timeout after {self.execution_timeout} seconds",
                'execution_time': time.time() - start_time
            }
        except Exception as e:
            logger.error(f"Error executing node {node_type}: {e}")
            logger.debug(traceback.format_exc())
            return {
                'success': False,
                'outputs': {},
                'logs': [],
                'error': str(e),
                'execution_time': time.time() - start_time
            }

    async def _execute_node_async(
        self,
        node_info: CustomNodeInfo,
        node_id: str,
        properties: Dict[str, Any],
        inputs: Dict[str, Any],
        context_data: Dict[str, Any],
        progress_callback: Optional[Callable]
    ) -> Dict[str, Any]:
        """Execute node in thread pool to avoid blocking."""

        loop = asyncio.get_event_loop()

        def _execute():
            # Create node instance
            node = node_info.node_class(**properties)
            node.id = node_id

            # Set inputs
            for input_name, input_value in inputs.items():
                node.set_input(input_name, input_value)

            # Create execution context
            context = ExecutionContext(
                node_id=node_id,
                workflow_id=context_data.get('workflow_id', ''),
                user_id=context_data.get('user_id', ''),
                progress_callback=progress_callback,
                dcc_servers=self._dcc_servers,
                dcc_executor=self._dcc_executor
            )

            # Execute
            try:
                result = node.execute(context)

                # Dict return is the standard pattern
                if isinstance(result, dict):
                    is_success = True
                    outputs = result
                    # Also merge any set_output() calls made during execution
                    for k, v in node.output_values.items():
                        if k not in outputs:
                            outputs[k] = v
                elif isinstance(result, bool):
                    # Legacy bool pattern — still works for backward compat
                    is_success = result
                    outputs = node.output_values
                else:
                    is_success = result is not None
                    outputs = node.output_values

                # Extract error message from logs if node reported failure
                error_msg = None
                if not is_success and context.logs:
                    error_entries = [l for l in context.logs if l.startswith("[ERROR]")]
                    if error_entries:
                        error_msg = error_entries[-1].replace("[ERROR] ", "", 1)
                    else:
                        error_msg = context.logs[-1].split("] ", 1)[-1] if context.logs else "Node returned failure"
                return {
                    'success': is_success,
                    'outputs': outputs,
                    'logs': context.logs,
                    'error': error_msg
                }
            except Exception as e:
                logger.error(f"Node execution error: {e}")
                logger.debug(traceback.format_exc())
                return {
                    'success': False,
                    'outputs': {},
                    'logs': context.logs + [f"[ERROR] {str(e)}"],
                    'error': str(e)
                }

        return await loop.run_in_executor(self._executor, _execute)

    def _start_file_watchers(self):
        """Start file watchers for hot reload."""
        for nodes_dir in self.nodes_dirs:
            if nodes_dir.exists():
                watcher = threading.Thread(
                    target=self._watch_directory,
                    args=(nodes_dir,),
                    daemon=True
                )
                watcher.start()
                self._file_watchers.append(watcher)
                logger.info(f"Started file watcher for: {nodes_dir}")

    def _watch_directory(self, directory: Path):
        """Watch a directory for file changes."""
        last_check: Dict[str, float] = {}

        # Initial state
        for py_file in directory.glob("*.py"):
            if not py_file.name.startswith("_"):
                last_check[str(py_file)] = py_file.stat().st_mtime

        while not self._stop_watching.is_set():
            try:
                current_files: Dict[str, float] = {}
                changed = False

                for py_file in directory.glob("*.py"):
                    if py_file.name.startswith("_"):
                        continue

                    file_path = str(py_file)
                    mtime = py_file.stat().st_mtime
                    current_files[file_path] = mtime

                    # Check if file is new or modified
                    if file_path not in last_check:
                        logger.info(f"[HOT RELOAD] New file detected: {py_file.name}")
                        changed = True
                    elif last_check[file_path] != mtime:
                        logger.info(f"[HOT RELOAD] File modified: {py_file.name}")
                        changed = True

                # Check for deleted files
                for file_path in last_check:
                    if file_path not in current_files:
                        logger.info(f"[HOT RELOAD] File deleted: {Path(file_path).name}")
                        changed = True

                if changed:
                    self._reload_all()

                last_check = current_files

            except Exception as e:
                logger.error(f"Error watching directory {directory}: {e}")

            # Poll every 2 seconds
            self._stop_watching.wait(2)

    def _reload_all(self):
        """Reload all nodes and notify callback."""
        old_count = len(self.discovered_nodes)
        self.discover_nodes()
        new_count = len(self.discovered_nodes)

        logger.info(f"[HOT RELOAD] Reloaded nodes: {old_count} -> {new_count}")

        if self._on_nodes_changed:
            try:
                self._on_nodes_changed(self.get_node_metadata())
            except Exception as e:
                logger.error(f"Error in on_nodes_changed callback: {e}")


# ============================================================================
# Configuration Loading
# ============================================================================

def load_custom_nodes_config() -> Dict[str, Any]:
    """
    Load custom nodes configuration from multiple sources.

    Priority (highest to lowest):
    1. Environment variable: PLUMBER_CUSTOM_NODES_PATH (semicolon-separated paths)
    2. Config file: ~/.plumber-agent/config.json
    3. Default: ~/plumber/custom_nodes/

    Config file format:
    {
        "custom_nodes": {
            "paths": [
                "~/plumber/custom_nodes",
                "/mnt/network/studio/plumber_nodes",
                "\\\\server\\share\\pipeline\\nodes"
            ],
            "hot_reload": true,
            "execution_timeout": 900
        }
    }
    """
    config = {
        "paths": [],
        "hot_reload": True,
        "execution_timeout": 900
    }

    # 1. Load from config file
    config_file = Path.home() / ".plumber-agent" / "config.json"
    if config_file.exists():
        try:
            with open(config_file, 'r') as f:
                file_config = json.load(f)
                custom_nodes_config = file_config.get("custom_nodes", {})

                if "paths" in custom_nodes_config:
                    config["paths"] = custom_nodes_config["paths"]
                if "hot_reload" in custom_nodes_config:
                    config["hot_reload"] = custom_nodes_config["hot_reload"]
                if "execution_timeout" in custom_nodes_config:
                    config["execution_timeout"] = custom_nodes_config["execution_timeout"]

                logger.info(f"Loaded custom nodes config from {config_file}")
        except Exception as e:
            logger.warning(f"Failed to load config file: {e}")

    # 2. Override with environment variable (highest priority)
    env_paths = os.environ.get("PLUMBER_CUSTOM_NODES_PATH", "")
    if env_paths:
        # Support both ; (Windows) and : (Unix) as separators
        separator = ";" if ";" in env_paths else ":"
        env_path_list = [p.strip() for p in env_paths.split(separator) if p.strip()]
        if env_path_list:
            config["paths"] = env_path_list
            logger.info(f"Using custom nodes paths from PLUMBER_CUSTOM_NODES_PATH: {env_path_list}")

    # 3. Always include default path if no paths configured
    if not config["paths"]:
        config["paths"] = [str(Path.home() / "plumber" / "custom_nodes")]

    return config


def resolve_node_paths(path_strings: List[str]) -> List[Path]:
    """
    Resolve path strings to Path objects, supporting:
    - Home directory expansion (~)
    - Environment variables ($VAR or %VAR%)
    - Network paths (UNC paths on Windows, mounted paths on Unix)
    """
    resolved = []

    for path_str in path_strings:
        try:
            # Expand ~ to home directory
            expanded = os.path.expanduser(path_str)

            # Expand environment variables
            expanded = os.path.expandvars(expanded)

            # Convert to Path object
            path = Path(expanded)

            # Check if path exists or is a network path we should try anyway
            if path.exists():
                resolved.append(path)
                logger.info(f"Custom nodes path: {path} (exists)")
            elif path_str.startswith("\\\\") or path_str.startswith("//"):
                # Network path - add even if not currently accessible
                resolved.append(path)
                logger.info(f"Custom nodes path: {path} (network path, may be available later)")
            else:
                logger.warning(f"Custom nodes path not found: {path}")

        except Exception as e:
            logger.warning(f"Failed to resolve path '{path_str}': {e}")

    return resolved


# ============================================================================
# Singleton Instance
# ============================================================================

_executor_instance: Optional[CustomNodeExecutor] = None


def get_custom_node_executor() -> CustomNodeExecutor:
    """Get the singleton CustomNodeExecutor instance."""
    global _executor_instance
    if _executor_instance is None:
        _executor_instance = CustomNodeExecutor()
    return _executor_instance


def initialize_custom_node_executor(
    nodes_dirs: Optional[List[Path]] = None,
    enable_hot_reload: bool = True,
    execution_timeout: int = 900,
    on_nodes_changed: Optional[Callable] = None
) -> CustomNodeExecutor:
    """
    Initialize and start the CustomNodeExecutor.

    If nodes_dirs is None, loads paths from configuration:
    1. Environment variable: PLUMBER_CUSTOM_NODES_PATH
    2. Config file: ~/.plumber-agent/config.json
    3. Default: ~/plumber/custom_nodes/
    """
    global _executor_instance

    # Load configuration if no paths provided
    if nodes_dirs is None:
        config = load_custom_nodes_config()
        nodes_dirs = resolve_node_paths(config["paths"])
        enable_hot_reload = config.get("hot_reload", True)
        execution_timeout = config.get("execution_timeout", 900)

        logger.info(f"Configured custom nodes directories: {[str(p) for p in nodes_dirs]}")

    _executor_instance = CustomNodeExecutor(
        nodes_dirs=nodes_dirs,
        enable_hot_reload=enable_hot_reload,
        execution_timeout=execution_timeout
    )
    _executor_instance.start(on_nodes_changed=on_nodes_changed)

    return _executor_instance
