"""
Local DCC Agent Server
FastAPI server running on user's local machine to handle DCC operations delegated from Railway.
"""

import asyncio
import json
import logging
import os
import uuid
from datetime import datetime
from typing import Dict, List, Optional, Any
import subprocess
import tempfile
from pathlib import Path

from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
import psutil
import websockets
import uvicorn

from .dcc_discovery import get_dcc_discovery
from .dcc_executor import DCCExecutor
from .connection_manager import ConnectionManager, ConnectionState
from .custom_node_executor import (
    get_custom_node_executor,
    initialize_custom_node_executor,
    CustomNodeExecutor
)

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

# Simple heartbeat function for long operations
async def simple_heartbeat(connection_manager, operation_id: str):
    """Send periodic heartbeat during long DCC operations."""
    interval = 10  # Send heartbeat every 10 seconds
    counter = 0

    while True:
        try:
            await asyncio.sleep(interval)
            counter += 1

            # Send heartbeat progress message
            heartbeat_msg = {
                "type": "dcc_operation_progress",
                "operation_id": operation_id,
                "progress": 0.5,  # Keep progress at 50% during heartbeat
                "message": f"DCC operation in progress... ({counter * interval}s)",
                "timestamp": datetime.now().isoformat(),
                "heartbeat": True
            }

            # Try to send heartbeat message
            await connection_manager.send_operation_progress(
                operation_id, 0.5, f"DCC operation in progress... ({counter * interval}s)"
            )

            logger.debug(f"💓 Heartbeat sent for operation {operation_id} (#{counter})")

        except asyncio.CancelledError:
            logger.debug(f"💓 Heartbeat stopped for operation {operation_id}")
            break
        except Exception as e:
            logger.warning(f"💓 Heartbeat failed for operation {operation_id}: {e}")
            # Continue trying

app = FastAPI(
    title="Plumber Local DCC Agent",
    description="Local agent for executing DCC operations on user's machine",
    version="2.0.0"
)

# Enable CORS for Railway backend
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://plumber-production-446f.up.railway.app",
        "http://localhost:8000",
        "http://localhost:3000"
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Global state
connected_clients: Dict[str, WebSocket] = {}
active_jobs: Dict[str, Dict] = {}
dcc_discovery = get_dcc_discovery()
dcc_executor = DCCExecutor()

# Connection manager - replaces old railway connection logic
connection_manager: Optional[ConnectionManager] = None

# Custom node executor - enables local custom node execution
custom_node_executor: Optional[CustomNodeExecutor] = None

# DCC-initiated workflow executions (Priority 0E)
dcc_workflow_executions: Dict[str, Dict] = {}  # execution_id -> status tracking

# Pydantic models
class DCCOperation(BaseModel):
    """DCC operation request model."""
    operation_id: str = Field(..., description="Unique operation ID")
    dcc_type: str = Field(..., description="DCC type (maya, blender, houdini)")
    operation_type: str = Field(..., description="Operation type (render, export, etc.)")
    parameters: Dict[str, Any] = Field(default_factory=dict, description="Operation parameters")
    input_files: List[str] = Field(default_factory=list, description="Input file paths")
    output_directory: str = Field(..., description="Output directory path")
    timeout: int = Field(default=300, description="Timeout in seconds")
    user_id: Optional[str] = Field(None, description="User ID for authentication")

class AgentStatus(BaseModel):
    """Agent status model."""
    agent_id: str
    status: str
    uptime: float
    dcc_status: Dict[str, Dict]
    active_jobs: int
    system_resources: Dict[str, Any]

class OperationResult(BaseModel):
    """Operation result model."""
    operation_id: str
    success: bool
    output_files: List[str] = Field(default_factory=list)
    logs: List[str] = Field(default_factory=list)
    error_message: Optional[str] = None
    execution_time: float
    metadata: Dict[str, Any] = Field(default_factory=dict)


class CustomNodeOperation(BaseModel):
    """Custom node operation request model."""
    operation_id: str = Field(..., description="Unique operation ID")
    node_type: str = Field(..., description="Custom node type (e.g., 'custom:MyNode')")
    node_id: str = Field(..., description="Node instance ID")
    properties: Dict[str, Any] = Field(default_factory=dict, description="Node property values")
    inputs: Dict[str, Any] = Field(default_factory=dict, description="Input values from connected nodes")
    context: Dict[str, Any] = Field(default_factory=dict, description="Execution context (workflow_id, user_id)")
    timeout: int = Field(default=300, description="Execution timeout in seconds")


class WorkflowTriggerRequest(BaseModel):
    """Request to trigger a workflow from a DCC application (Priority 0E)."""
    workflow_path: str = Field(..., description="Absolute path to .damn/.json workflow file")
    dcc_context: Dict[str, Any] = Field(default_factory=dict, description="DCC context to inject (scene_file, selected_objects, etc.)")
    parameter_overrides: Dict[str, Any] = Field(default_factory=dict, description="Override node properties by 'NodeLabel.property_name'")
    user_id: Optional[str] = Field(None, description="User ID (if known)")

# Agent ID - unique identifier for this agent instance
AGENT_ID = str(uuid.uuid4())
START_TIME = datetime.now()

# Railway backend configuration
RAILWAY_BACKEND_URL = "https://plumber-production-446f.up.railway.app"

async def process_dcc_operation(operation_data: dict):
    """Process DCC operation received via connection manager."""
    try:
        operation_id = operation_data.get('operation_id')
        logger.info(f"🎬 Processing DCC operation: {operation_id}")

        # Convert dict to DCCOperation object
        operation = DCCOperation(**operation_data)

        # Send operation start notification
        await connection_manager.send_operation_response(operation_id, "started", {
            "message": f"Starting {operation.dcc_type} operation",
            "progress": 0
        })

        # Check if DCC is available
        dcc_status = dcc_discovery.get_dcc_status()
        if not dcc_status.get(operation.dcc_type, {}).get('available'):
            error_msg = f"{operation.dcc_type.title()} is not available on this system"
            logger.error(f"❌ {error_msg}")
            await connection_manager.send_operation_response(operation_id, "failed", {
                "error": error_msg
            })
            return

        # Add to active jobs
        active_jobs[operation_id] = {
            "operation": operation.dict(),
            "status": "running",
            "start_time": datetime.now(),
            "progress": 0
        }

        # Execute the operation with progress callback and heartbeat support
        async def progress_callback(progress: int, message: str):
            logger.info(f"📊 [PROGRESS] {operation_id}: {progress}% - {message}")
            await connection_manager.send_operation_progress(operation_id, progress / 100.0, message)
            await connection_manager.send_operation_response(operation_id, "progress", {
                "progress": progress,
                "message": message
            })
            active_jobs[operation_id]["progress"] = progress

        # Execute operation with simple heartbeat approach
        heartbeat_task = None
        try:
            # Start simple heartbeat during operation
            heartbeat_task = asyncio.create_task(
                simple_heartbeat(connection_manager, operation_id)
            )

            # Execute the actual operation
            result = await dcc_executor.execute_operation(operation, progress_callback)

        finally:
            # Stop heartbeat
            if heartbeat_task:
                heartbeat_task.cancel()
                try:
                    await heartbeat_task
                except asyncio.CancelledError:
                    pass

        # Update job status
        active_jobs[operation_id]["status"] = "completed"
        active_jobs[operation_id]["result"] = result.dict()

        # DEBUG: Log result details
        logger.info(f"[AGENT RESULT DEBUG] Operation {operation_id} result.dict() keys: {list(result.dict().keys())}")
        logger.info(f"[AGENT RESULT DEBUG] result.metadata: {result.metadata}")
        logger.info(f"[AGENT RESULT DEBUG] Full result.dict(): {result.dict()}")

        # Send success response
        await connection_manager.send_operation_response(operation_id, "completed", {
            "result": result.dict(),
            "execution_time": result.execution_time,
            "success": result.success
        })

        logger.info(f"✅ Operation {operation_id} completed successfully")

        # Schedule cleanup after 5 minutes
        asyncio.create_task(cleanup_job_delayed(operation_id, 300))

    except Exception as e:
        operation_id = operation_data.get('operation_id', 'unknown')
        error_msg = str(e)
        logger.error(f"❌ Operation {operation_id} failed: {error_msg}")

        # Update job status
        if operation_id in active_jobs:
            active_jobs[operation_id]["status"] = "failed"
            active_jobs[operation_id]["error"] = error_msg

        # Send error response
        await connection_manager.send_operation_response(operation_id, "failed", {
            "error": error_msg
        })

async def cleanup_job_delayed(operation_id: str, delay: int):
    """Clean up job after delay."""
    await asyncio.sleep(delay)
    if operation_id in active_jobs:
        del active_jobs[operation_id]
        logger.debug(f"🧹 Cleaned up job: {operation_id}")

async def handle_dcc_operation_message(data: Dict[str, Any]):
    """Handle DCC operation message from Railway backend."""
    operation_data = data.get('data', {})
    operation_id = operation_data.get('operation_id')
    logger.info(f"📨 Received DCC operation request: {operation_id}")

    # Process DCC operation asynchronously
    asyncio.create_task(process_dcc_operation(operation_data))


async def handle_custom_node_operation_message(data: Dict[str, Any]):
    """Handle custom node operation message from Railway backend."""
    operation_data = data.get('data', {})
    operation_id = operation_data.get('operation_id', str(uuid.uuid4()))
    node_type = operation_data.get('node_type', '')
    logger.info(f"📨 Received custom node operation: {operation_id} ({node_type})")

    # Process custom node operation asynchronously
    asyncio.create_task(process_custom_node_operation(operation_data))


async def handle_get_custom_nodes_message(data: Dict[str, Any]):
    """Handle request for custom node metadata from Railway backend."""
    logger.info("📨 Received request for custom node metadata")

    if custom_node_executor:
        metadata = custom_node_executor.get_node_metadata()
        await connection_manager.send_message({
            "type": "custom_nodes_list",
            "data": {
                "nodes": metadata,
                "count": len(metadata),
                "timestamp": datetime.now().isoformat()
            }
        })
        logger.info(f"📤 Sent {len(metadata)} custom node definitions to backend")
    else:
        await connection_manager.send_message({
            "type": "custom_nodes_list",
            "data": {
                "nodes": [],
                "count": 0,
                "error": "Custom node executor not initialized",
                "timestamp": datetime.now().isoformat()
            }
        })


async def process_custom_node_operation(operation_data: dict):
    """Process custom node operation received via connection manager."""
    operation_id = operation_data.get('operation_id', str(uuid.uuid4()))

    try:
        node_type = operation_data.get('node_type', '')
        node_id = operation_data.get('node_id', '')
        properties = operation_data.get('properties', {})
        inputs = operation_data.get('inputs', {})
        context_data = operation_data.get('context', {})

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

        if not custom_node_executor:
            error_msg = "Custom node executor not initialized"
            logger.error(f"❌ {error_msg}")
            await connection_manager.send_operation_response(operation_id, "failed", {
                "error": error_msg
            })
            return

        # Send operation start notification
        await connection_manager.send_operation_response(operation_id, "started", {
            "message": f"Executing custom node: {node_type}",
            "progress": 0
        })

        # Progress callback for real-time updates
        async def progress_callback(progress: float, message: str):
            logger.info(f"📊 [PROGRESS] {operation_id}: {progress*100:.0f}% - {message}")
            await connection_manager.send_operation_progress(operation_id, progress, message)

        # Execute the custom node
        result = await custom_node_executor.execute_node(
            node_type=node_type,
            node_id=node_id,
            properties=properties,
            inputs=inputs,
            context_data=context_data,
            progress_callback=progress_callback
        )

        if result['success']:
            await connection_manager.send_operation_response(operation_id, "completed", {
                "result": result,
                "outputs": result.get('outputs', {}),
                "logs": result.get('logs', []),
                "execution_time": result.get('execution_time', 0),
                "success": True
            })
            logger.info(f"✅ Custom node {node_type} completed successfully")
        else:
            await connection_manager.send_operation_response(operation_id, "failed", {
                "error": result.get('error', 'Unknown error'),
                "logs": result.get('logs', []),
                "execution_time": result.get('execution_time', 0)
            })
            logger.error(f"❌ Custom node {node_type} failed: {result.get('error')}")

    except Exception as e:
        error_msg = str(e)
        logger.error(f"❌ Custom node operation {operation_id} failed: {error_msg}")
        await connection_manager.send_operation_response(operation_id, "failed", {
            "error": error_msg
        })

@app.on_event("startup")
async def startup_event():
    """Initialize agent on startup."""
    global connection_manager, custom_node_executor

    logger.info(f"[START] Local DCC Agent v2.0.0 starting up (ID: {AGENT_ID})")
    logger.info("[INFO] Features: Enhanced connection stability, Exponential backoff, Multi-path communication")
    logger.info("[INFO] Features: Custom Node Executor with hot reload")
    logger.info("[INFO] Compatible with Railway backend v2.2.0+")

    # Discover DCC installations
    discovery_results = dcc_discovery.discover_all()
    logger.info("[DISCOVERY] DCC Discovery completed:")
    for dcc_name, dcc_info in discovery_results.items():
        if dcc_info['available']:
            logger.info(f"  ✅ {dcc_name.title()}: {dcc_info['version']}")
        else:
            logger.info(f"  ❌ {dcc_name.title()}: Not found")

    # Initialize DCC executor
    await dcc_executor.initialize()

    # Initialize Custom Node Executor
    logger.info("[CUSTOM NODES] Initializing Custom Node Executor...")

    # Capture the event loop — the hot reload watcher runs in a daemon thread,
    # so we need run_coroutine_threadsafe() instead of create_task().
    _loop = asyncio.get_event_loop()

    async def on_custom_nodes_changed(metadata: List[Dict]):
        """Callback when custom nodes change (hot reload)."""
        logger.info(f"[CUSTOM NODES] Nodes changed: {len(metadata)} nodes available")
        # Send updated metadata to backend
        if connection_manager:
            await connection_manager.send_message({
                "type": "custom_nodes_updated",
                "data": {
                    "nodes": metadata,
                    "timestamp": datetime.now().isoformat()
                }
            })

    custom_node_executor = initialize_custom_node_executor(
        enable_hot_reload=True,
        on_nodes_changed=lambda m: asyncio.run_coroutine_threadsafe(on_custom_nodes_changed(m), _loop)
    )

    # Wire DCC servers to custom node executor so custom nodes can call context.execute_blender() etc.
    custom_node_executor.set_dcc_servers(dcc_executor)

    custom_node_count = len(custom_node_executor.get_node_list())
    logger.info(f"[CUSTOM NODES] Initialized with {custom_node_count} custom nodes")
    if custom_node_count > 0:
        logger.info(f"[CUSTOM NODES] Available: {', '.join(custom_node_executor.get_node_list())}")

    # Start periodic GUI DCC server refresh (detect newly opened/closed DCC GUIs)
    async def _gui_refresh_loop():
        while True:
            try:
                await asyncio.sleep(30)  # Check every 30 seconds
                await dcc_executor.refresh_gui_servers()
                # Update connection manager with latest GUI status
                if connection_manager:
                    connection_manager.gui_dcc_status = dcc_executor.get_gui_status()
                # Re-wire DCC servers for custom nodes (picks up newly detected GUI servers)
                if custom_node_executor:
                    custom_node_executor.set_dcc_servers(dcc_executor)
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.debug(f"[GUI] Refresh loop error: {e}")
                await asyncio.sleep(30)

    asyncio.create_task(_gui_refresh_loop())

    # Format DCC applications data for backend
    dcc_applications = {}
    for dcc_name, dcc_info in discovery_results.items():
        dcc_applications[dcc_name] = {
            "available": dcc_info.get('available', False),
            "version": dcc_info.get('version'),
            "executable": dcc_info.get('executable'),
            "python_executable": dcc_info.get('python_executable')
        }

    # System info
    system_info = {
        "agent_version": "2.0.0",
        "platform": "windows",
        "enhanced_features": True,
        "supported_operations": ["render", "export", "import", "script", "scene_info"],
        "custom_nodes_enabled": True,
        "custom_node_count": custom_node_count
    }

    # Get initial GUI DCC status
    gui_dcc_status = dcc_executor.get_gui_status()

    # Load license credentials
    from .license_manager import AgentLicenseManager
    _license_mgr = AgentLicenseManager()
    _license_key = _license_mgr.load_license()
    _machine_fingerprint = _license_mgr.generate_fingerprint()
    _machine_name = _license_mgr.get_machine_name()
    if _license_key:
        logger.info(f"[LICENSE] License key loaded: {_license_mgr._mask_key(_license_key)}")
    else:
        logger.warning("[LICENSE] No license key found. Agent will connect as unlicensed.")

    # Initialize enhanced connection manager
    connection_manager = ConnectionManager(
        agent_id=AGENT_ID,
        railway_url=RAILWAY_BACKEND_URL,
        dcc_applications=dcc_applications,
        system_info=system_info,
        gui_dcc_status=gui_dcc_status,
        license_key=_license_key,
        machine_fingerprint=_machine_fingerprint,
        machine_name=_machine_name,
        license_manager=_license_mgr,
    )

    # Register message handlers
    connection_manager.register_message_handler("dcc_operation", handle_dcc_operation_message)
    connection_manager.register_message_handler("custom_node_operation", handle_custom_node_operation_message)
    connection_manager.register_message_handler("get_custom_nodes", handle_get_custom_nodes_message)
    connection_manager.register_message_handler("workflow_execute_result", handle_workflow_execute_result)
    connection_manager.register_message_handler("workflow_execute_progress", handle_workflow_execute_progress)

    # Start connection manager
    await connection_manager.start()
    logger.info("✅ Enhanced Connection Manager initialized")

@app.get("/")
async def root():
    """Root endpoint with agent information."""
    connection_status = connection_manager.get_connection_status() if connection_manager else {"state": "initializing"}

    return {
        "message": "Plumber Local DCC Agent v2.0.0",
        "agent_id": AGENT_ID,
        "status": "running",
        "uptime": (datetime.now() - START_TIME).total_seconds(),
        "version": "2.0.0",
        "enhanced_features": True,
        "connection_status": connection_status
    }

@app.get("/version")
async def get_version():
    """Get detailed version information."""
    connection_status = connection_manager.get_connection_status() if connection_manager else {"state": "initializing"}
    custom_node_count = len(custom_node_executor.get_node_list()) if custom_node_executor else 0

    return {
        "agent_version": "2.0.0",
        "agent_id": AGENT_ID,
        "enhanced_features": True,
        "features": [
            "Exponential backoff reconnection",
            "Connection state persistence",
            "Bidirectional heartbeat system",
            "Multi-path communication (WebSocket + HTTP)",
            "Circuit breaker pattern",
            "Real-time connection quality monitoring",
            "Message queuing during disconnections",
            "Maya/Blender/Houdini support",
            "Custom Node Executor with hot reload",
            "Local-only node execution (code stays private)"
        ],
        "custom_nodes": {
            "enabled": custom_node_executor is not None,
            "count": custom_node_count,
            "hot_reload": custom_node_executor.enable_hot_reload if custom_node_executor else False,
            "nodes_directory": str(custom_node_executor.DEFAULT_NODES_DIR) if custom_node_executor else None
        },
        "connection_status": connection_status,
        "compatible_backend_version": "2.2.0+",
        "last_updated": "2026-02-04",
        "timestamp": datetime.now().isoformat()
    }

@app.get("/version/check")
async def check_latest_version():
    """Check if this agent is the latest version by comparing with Railway backend."""
    try:
        import aiohttp
        async with aiohttp.ClientSession() as session:
            # Check Railway backend version
            async with session.get("https://plumber-production-446f.up.railway.app/api/version") as response:
                if response.status == 200:
                    backend_info = await response.json()
                    backend_version = backend_info.get("version", "unknown")

                    # Simple version comparison (you can make this more sophisticated)
                    current_version = "2.0.0"
                    is_latest = True  # For now, assume latest until we have version server

                    return {
                        "current_version": current_version,
                        "backend_version": backend_version,
                        "is_latest": is_latest,
                        "status": "up-to-date" if is_latest else "update-available",
                        "heartbeat_compatible": True,
                        "check_time": datetime.now().isoformat()
                    }
                else:
                    return {
                        "current_version": "2.0.0",
                        "status": "check-failed",
                        "error": f"Backend check failed: {response.status}",
                        "check_time": datetime.now().isoformat()
                    }
    except Exception as e:
        return {
            "current_version": "2.0.0",
            "status": "check-failed",
            "error": str(e),
            "check_time": datetime.now().isoformat()
        }

@app.get("/health")
async def health_check():
    """Health check endpoint."""
    system_resources = {
        "cpu_percent": psutil.cpu_percent(interval=1),
        "memory_percent": psutil.virtual_memory().percent,
        "disk_percent": psutil.disk_usage('/').percent if os.name != 'nt' else psutil.disk_usage('C:').percent
    }

    connection_status = connection_manager.get_connection_status() if connection_manager else {"state": "initializing"}

    return {
        "status": "healthy",
        "agent_id": AGENT_ID,
        "uptime": (datetime.now() - START_TIME).total_seconds(),
        "active_jobs": len(active_jobs),
        "system_resources": system_resources,
        "connection_status": connection_status,
        "connection_quality": connection_status.get("quality", 0.0),
        "timestamp": datetime.now().isoformat()
    }

@app.get("/status", response_model=AgentStatus)
async def get_agent_status():
    """Get detailed agent status."""
    uptime = (datetime.now() - START_TIME).total_seconds()

    system_resources = {
        "cpu_percent": psutil.cpu_percent(interval=1),
        "memory": {
            "percent": psutil.virtual_memory().percent,
            "available_gb": psutil.virtual_memory().available / (1024**3),
            "total_gb": psutil.virtual_memory().total / (1024**3)
        },
        "disk": {
            "percent": psutil.disk_usage('C:').percent if os.name == 'nt' else psutil.disk_usage('/').percent,
        }
    }

    return AgentStatus(
        agent_id=AGENT_ID,
        status="running",
        uptime=uptime,
        dcc_status=dcc_discovery.get_dcc_status(),
        active_jobs=len(active_jobs),
        system_resources=system_resources
    )

@app.get("/dcc/discovery")
async def get_dcc_discovery_results():
    """Get DCC discovery results."""
    return {
        "agent_id": AGENT_ID,
        "discovery_results": dcc_discovery.discovered_dccs,
        "dcc_status": dcc_discovery.get_dcc_status(),
        "timestamp": datetime.now().isoformat()
    }


# ============================================================================
# Custom Node Endpoints
# ============================================================================

@app.get("/custom-nodes/list")
async def get_custom_nodes_list():
    """Get list of available custom nodes."""
    if not custom_node_executor:
        return {
            "error": "Custom node executor not initialized",
            "nodes": [],
            "count": 0,
            "timestamp": datetime.now().isoformat()
        }

    node_list = custom_node_executor.get_node_list()
    return {
        "agent_id": AGENT_ID,
        "nodes": node_list,
        "count": len(node_list),
        "nodes_directory": str(custom_node_executor.DEFAULT_NODES_DIR),
        "hot_reload_enabled": custom_node_executor.enable_hot_reload,
        "timestamp": datetime.now().isoformat()
    }


@app.get("/custom-nodes/metadata")
async def get_custom_nodes_metadata():
    """
    Get full metadata for all custom nodes.
    This is what gets sent to the backend for node registration.
    """
    if not custom_node_executor:
        return {
            "error": "Custom node executor not initialized",
            "nodes": [],
            "count": 0,
            "timestamp": datetime.now().isoformat()
        }

    metadata = custom_node_executor.get_node_metadata()
    return {
        "agent_id": AGENT_ID,
        "nodes": metadata,
        "count": len(metadata),
        "timestamp": datetime.now().isoformat()
    }


@app.get("/custom-nodes/{node_type}")
async def get_custom_node_info(node_type: str):
    """Get detailed information about a specific custom node."""
    if not custom_node_executor:
        raise HTTPException(status_code=503, detail="Custom node executor not initialized")

    # Check in discovered nodes
    node_info = custom_node_executor.discovered_nodes.get(node_type)
    if not node_info:
        raise HTTPException(status_code=404, detail=f"Custom node '{node_type}' not found")

    return {
        "name": node_info.name,
        "category": node_info.category,
        "icon": node_info.icon,
        "description": node_info.description,
        "color": node_info.color,
        "properties": node_info.properties,
        "inputs": node_info.inputs,
        "outputs": node_info.outputs,
        "file_path": node_info.file_path,
        "file_hash": node_info.file_hash,
        "load_error": node_info.load_error,
        "timestamp": datetime.now().isoformat()
    }


@app.post("/custom-nodes/execute")
async def execute_custom_node(operation: CustomNodeOperation):
    """Execute a custom node via HTTP (alternative to WebSocket)."""
    logger.info(f"🔧 HTTP Execute custom node: {operation.node_type} (id: {operation.node_id})")

    if not custom_node_executor:
        raise HTTPException(status_code=503, detail="Custom node executor not initialized")

    # Check if node exists
    node_type = operation.node_type
    if node_type.startswith("custom:"):
        node_type = node_type[7:]

    if node_type not in custom_node_executor.discovered_nodes:
        raise HTTPException(status_code=404, detail=f"Custom node '{node_type}' not found")

    # Execute the node
    result = await custom_node_executor.execute_node(
        node_type=operation.node_type,
        node_id=operation.node_id,
        properties=operation.properties,
        inputs=operation.inputs,
        context_data=operation.context
    )

    return {
        "operation_id": operation.operation_id,
        "success": result['success'],
        "outputs": result.get('outputs', {}),
        "logs": result.get('logs', []),
        "error": result.get('error'),
        "execution_time": result.get('execution_time', 0),
        "timestamp": datetime.now().isoformat()
    }


@app.post("/custom-nodes/reload")
async def reload_custom_nodes():
    """Force reload all custom nodes (hot reload manually triggered)."""
    if not custom_node_executor:
        raise HTTPException(status_code=503, detail="Custom node executor not initialized")

    old_count = len(custom_node_executor.get_node_list())
    custom_node_executor.discover_nodes()
    new_count = len(custom_node_executor.get_node_list())

    # Send updated metadata to backend
    if connection_manager:
        metadata = custom_node_executor.get_node_metadata()
        await connection_manager.send_message({
            "type": "custom_nodes_updated",
            "data": {
                "nodes": metadata,
                "timestamp": datetime.now().isoformat()
            }
        })

    return {
        "message": "Custom nodes reloaded",
        "previous_count": old_count,
        "current_count": new_count,
        "nodes": custom_node_executor.get_node_list(),
        "timestamp": datetime.now().isoformat()
    }

@app.get("/connection/status")
async def get_connection_status():
    """Get detailed connection status."""
    if not connection_manager:
        return {
            "error": "Connection manager not initialized",
            "timestamp": datetime.now().isoformat()
        }

    connection_status = connection_manager.get_connection_status()

    return {
        "agent_id": AGENT_ID,
        "connection_status": connection_status,
        "railway_backend": RAILWAY_BACKEND_URL,
        "enhanced_features_enabled": True,
        "timestamp": datetime.now().isoformat()
    }

@app.post("/dcc/execute", response_model=OperationResult)
async def execute_dcc_operation(
    operation: DCCOperation,
    background_tasks: BackgroundTasks
):
    """Execute DCC operation."""
    logger.info(f"🎬 Received DCC operation: {operation.operation_id} ({operation.dcc_type})")

    # Check if DCC is available
    dcc_status = dcc_discovery.get_dcc_status()
    if not dcc_status.get(operation.dcc_type, {}).get('available'):
        raise HTTPException(
            status_code=400,
            detail=f"{operation.dcc_type.title()} is not available on this system"
        )

    # Check if operation is already running
    if operation.operation_id in active_jobs:
        raise HTTPException(
            status_code=409,
            detail=f"Operation {operation.operation_id} is already running"
        )

    # Add to active jobs
    active_jobs[operation.operation_id] = {
        "operation": operation.dict(),
        "status": "running",
        "start_time": datetime.now(),
        "progress": 0
    }

    try:
        # Execute the operation
        result = await dcc_executor.execute_operation(operation)

        # Update job status
        active_jobs[operation.operation_id]["status"] = "completed"
        active_jobs[operation.operation_id]["result"] = result.dict()

        # Schedule cleanup after 5 minutes
        background_tasks.add_task(cleanup_job, operation.operation_id, delay=300)

        logger.info(f"✅ Operation {operation.operation_id} completed successfully")
        return result

    except Exception as e:
        # Update job status
        active_jobs[operation.operation_id]["status"] = "failed"
        active_jobs[operation.operation_id]["error"] = str(e)

        # Schedule cleanup after 1 minute for failed jobs
        background_tasks.add_task(cleanup_job, operation.operation_id, delay=60)

        logger.error(f"❌ Operation {operation.operation_id} failed: {e}")
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/jobs")
async def get_active_jobs():
    """Get list of active jobs."""
    return {
        "agent_id": AGENT_ID,
        "active_jobs": active_jobs,
        "job_count": len(active_jobs),
        "timestamp": datetime.now().isoformat()
    }

@app.get("/jobs/{operation_id}")
async def get_job_status(operation_id: str):
    """Get status of specific job."""
    if operation_id not in active_jobs:
        raise HTTPException(status_code=404, detail="Job not found")

    return {
        "operation_id": operation_id,
        "job_info": active_jobs[operation_id],
        "timestamp": datetime.now().isoformat()
    }

@app.delete("/jobs/{operation_id}")
async def cancel_job(operation_id: str):
    """Cancel running job."""
    if operation_id not in active_jobs:
        raise HTTPException(status_code=404, detail="Job not found")

    job = active_jobs[operation_id]
    if job["status"] == "running":
        # Cancel the operation
        await dcc_executor.cancel_operation(operation_id)
        job["status"] = "cancelled"
        job["end_time"] = datetime.now()

        logger.info(f"🚫 Operation {operation_id} cancelled")
        return {"message": f"Operation {operation_id} cancelled"}
    else:
        return {"message": f"Operation {operation_id} is not running (status: {job['status']})"}


# =====================================================
# DCC-Initiated Workflow Execution (Priority 0E)
# =====================================================

def inject_dcc_context(workflow_data: dict, dcc_context: dict):
    """Inject DCC context values into workflow node properties.

    Matching strategy (in priority order):
    1. Node has a property named 'dcc_context_key' matching a context key
    2. Node label matches a context key (case-insensitive, underscores/spaces normalized)
    3. Remaining unmatched context goes into workflow metadata
    """
    matched_keys = set()

    for node in workflow_data.get("nodes", []):
        node_data = node.get("data", {})
        node_label = (node_data.get("label") or node.get("label") or "").strip()

        # Strategy 1: Explicit dcc_context_key property
        context_key = node_data.get("dcc_context_key")
        if context_key and context_key in dcc_context:
            node_data["value"] = dcc_context[context_key]
            matched_keys.add(context_key)
            continue

        # Strategy 2: Label matching (normalize spaces/underscores)
        normalized_label = node_label.lower().replace(" ", "_").replace("-", "_")
        for key, value in dcc_context.items():
            normalized_key = key.lower().replace(" ", "_").replace("-", "_")
            if normalized_key == normalized_label:
                node_data["value"] = value
                matched_keys.add(key)
                break

    # Strategy 3: Inject unmatched context as workflow metadata
    unmatched = {k: v for k, v in dcc_context.items() if k not in matched_keys}
    if unmatched:
        if "metadata" not in workflow_data:
            workflow_data["metadata"] = {}
        workflow_data["metadata"]["dcc_context"] = unmatched


def apply_parameter_overrides(workflow_data: dict, overrides: dict):
    """Apply parameter overrides to workflow nodes.

    Override keys use format: "NodeLabel.property_name" or "node_id.property_name"
    """
    for override_key, override_value in overrides.items():
        parts = override_key.split(".", 1)
        if len(parts) != 2:
            logger.warning(f"[DCC-WORKFLOW] Invalid override key (missing dot separator): {override_key}")
            continue
        target, prop_name = parts

        for node in workflow_data.get("nodes", []):
            node_data = node.get("data", {})
            node_label = (node_data.get("label") or "").strip()
            node_id = node.get("id", "")

            if node_label == target or node_id == target:
                node_data[prop_name] = override_value
                logger.info(f"[DCC-WORKFLOW] Override applied: {override_key} = {override_value}")
                break


async def submit_workflow_to_backend(execution_id: str, workflow_data: dict, user_id: Optional[str]):
    """Submit workflow to Railway backend via WebSocket and track results."""
    try:
        execution = dcc_workflow_executions[execution_id]
        execution["status"] = "submitted"
        execution["message"] = "Sending workflow to backend..."

        message = {
            "type": "workflow_execute_request",
            "execution_id": execution_id,
            "workflow": {
                "nodes": workflow_data.get("nodes", []),
                "connections": workflow_data.get("connections", [])
            },
            "metadata": workflow_data.get("metadata", {}),
            "user_id": user_id,
            "verbose": False
        }

        success = await connection_manager.send_message(message)

        if not success:
            execution["status"] = "failed"
            execution["error"] = "Failed to send workflow to backend (WebSocket send failed)"
            execution["completed_at"] = datetime.now().isoformat()
            logger.error(f"[DCC-WORKFLOW] WebSocket send failed for {execution_id}")
            return

        execution["message"] = "Workflow sent to backend, awaiting execution..."
        logger.info(f"[DCC-WORKFLOW] Workflow {execution_id} sent to backend")

    except Exception as e:
        execution = dcc_workflow_executions.get(execution_id)
        if execution:
            execution["status"] = "failed"
            execution["error"] = str(e)
            execution["completed_at"] = datetime.now().isoformat()
        logger.error(f"[DCC-WORKFLOW] Submit failed for {execution_id}: {e}")


async def handle_workflow_execute_result(data: Dict[str, Any]):
    """Handle workflow execution result from backend."""
    execution_id = data.get("execution_id")
    if not execution_id or execution_id not in dcc_workflow_executions:
        logger.warning(f"[DCC-WORKFLOW] Received result for unknown execution: {execution_id}")
        return

    execution = dcc_workflow_executions[execution_id]
    result_data = data.get("data", {})

    execution["status"] = result_data.get("status", "completed")
    execution["progress"] = result_data.get("progress", 100.0)
    execution["message"] = result_data.get("message", "Workflow completed")
    execution["results"] = result_data.get("results")
    execution["backend_workflow_id"] = result_data.get("workflow_id")
    execution["completed_at"] = datetime.now().isoformat()

    if result_data.get("error"):
        execution["error"] = result_data["error"]
        execution["status"] = "failed"

    logger.info(f"[DCC-WORKFLOW] Execution {execution_id} finished: {execution['status']}")


async def handle_workflow_execute_progress(data: Dict[str, Any]):
    """Handle workflow execution progress update from backend."""
    execution_id = data.get("execution_id")
    if execution_id and execution_id in dcc_workflow_executions:
        execution = dcc_workflow_executions[execution_id]
        execution["status"] = "running"
        execution["progress"] = data.get("progress", execution["progress"])
        execution["message"] = data.get("message", execution["message"])


def cleanup_expired_executions():
    """Remove completed/failed executions older than 1 hour."""
    now = datetime.now()
    expired = []
    for eid, execution in dcc_workflow_executions.items():
        if execution.get("completed_at"):
            try:
                completed = datetime.fromisoformat(execution["completed_at"])
                if (now - completed).total_seconds() > 3600:
                    expired.append(eid)
            except (ValueError, TypeError):
                pass
    for eid in expired:
        del dcc_workflow_executions[eid]
    if expired:
        logger.info(f"[DCC-WORKFLOW] Cleaned up {len(expired)} expired executions")


@app.post("/workflows/trigger")
async def trigger_workflow(request: WorkflowTriggerRequest):
    """Trigger a workflow execution from a DCC application.

    DCC scripts (Blender, Maya, Houdini) call this endpoint to execute
    a saved .damn workflow file through the Plumber backend.
    """
    execution_id = str(uuid.uuid4())

    # 1. Validate backend connection
    if not connection_manager or connection_manager.state != ConnectionState.CONNECTED:
        raise HTTPException(
            status_code=503,
            detail="Not connected to Plumber backend. Is the agent online and connected?"
        )

    # 2. Validate and read workflow file
    workflow_path = Path(request.workflow_path).expanduser().resolve()
    if not workflow_path.exists():
        raise HTTPException(status_code=404, detail=f"Workflow file not found: {workflow_path}")
    if workflow_path.suffix not in ('.damn', '.json'):
        raise HTTPException(status_code=400, detail="Workflow file must be .damn or .json")

    try:
        with open(workflow_path, 'r', encoding='utf-8') as f:
            workflow_data = json.load(f)
    except json.JSONDecodeError as e:
        raise HTTPException(status_code=400, detail=f"Invalid JSON in workflow file: {e}")

    # 3. Validate workflow structure
    if 'nodes' not in workflow_data or 'connections' not in workflow_data:
        raise HTTPException(status_code=400, detail="Invalid workflow: missing 'nodes' or 'connections'")

    if len(workflow_data['nodes']) == 0:
        raise HTTPException(status_code=400, detail="Workflow has no nodes")

    # 4. Inject DCC context into node properties
    if request.dcc_context:
        inject_dcc_context(workflow_data, request.dcc_context)

    # 5. Apply parameter overrides
    if request.parameter_overrides:
        apply_parameter_overrides(workflow_data, request.parameter_overrides)

    # 6. Track execution
    dcc_workflow_executions[execution_id] = {
        "execution_id": execution_id,
        "workflow_path": str(workflow_path),
        "workflow_name": workflow_data.get("name", workflow_path.stem),
        "status": "pending",
        "progress": 0.0,
        "message": "Workflow submitted to backend",
        "backend_workflow_id": None,
        "started_at": datetime.now().isoformat(),
        "completed_at": None,
        "results": None,
        "error": None,
    }

    # 7. Cleanup old executions
    cleanup_expired_executions()

    # 8. Submit to backend via WebSocket
    asyncio.create_task(submit_workflow_to_backend(execution_id, workflow_data, request.user_id))

    logger.info(f"[DCC-WORKFLOW] Triggered workflow from {workflow_path.name}, execution_id={execution_id}")

    return {
        "execution_id": execution_id,
        "status": "pending",
        "workflow_name": workflow_data.get("name", workflow_path.stem),
        "message": "Workflow submitted for execution",
        "status_url": f"http://127.0.0.1:8001/workflows/{execution_id}/status"
    }


@app.get("/workflows/{execution_id}/status")
async def get_workflow_status(execution_id: str):
    """Get status of a DCC-triggered workflow execution."""
    if execution_id not in dcc_workflow_executions:
        raise HTTPException(status_code=404, detail="Execution not found")
    return dcc_workflow_executions[execution_id]


@app.get("/workflows/{execution_id}/result")
async def get_workflow_result(execution_id: str):
    """Get final results of a completed DCC-triggered workflow execution."""
    if execution_id not in dcc_workflow_executions:
        raise HTTPException(status_code=404, detail="Execution not found")
    execution = dcc_workflow_executions[execution_id]
    if execution["status"] not in ("completed", "failed"):
        raise HTTPException(
            status_code=409,
            detail=f"Workflow not finished yet (status: {execution['status']})"
        )
    return execution


@app.get("/workflows")
async def list_workflow_executions():
    """List all DCC-triggered workflow executions (recent)."""
    return {
        "executions": list(dcc_workflow_executions.values()),
        "count": len(dcc_workflow_executions)
    }


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    """WebSocket endpoint for real-time communication with Railway."""
    await websocket.accept()
    client_id = str(uuid.uuid4())
    connected_clients[client_id] = websocket

    logger.info(f"🔌 WebSocket client connected: {client_id}")

    try:
        # Send initial status
        await websocket.send_json({
            "type": "agent_status",
            "data": {
                "agent_id": AGENT_ID,
                "status": "connected",
                "dcc_status": dcc_discovery.get_dcc_status(),
                "timestamp": datetime.now().isoformat()
            }
        })

        # Listen for messages
        while True:
            data = await websocket.receive_json()
            await handle_websocket_message(websocket, data)

    except WebSocketDisconnect:
        logger.info(f"🔌 WebSocket client disconnected: {client_id}")
    except Exception as e:
        logger.error(f"WebSocket error: {e}")
    finally:
        if client_id in connected_clients:
            del connected_clients[client_id]

async def handle_websocket_message(websocket: WebSocket, data: Dict):
    """Handle incoming WebSocket message."""
    message_type = data.get("type")

    if message_type == "ping":
        await websocket.send_json({
            "type": "pong",
            "timestamp": datetime.now().isoformat()
        })
    elif message_type == "get_status":
        status = await get_agent_status()
        await websocket.send_json({
            "type": "status_response",
            "data": status.dict()
        })
    elif message_type == "dcc_operation":
        operation_data = data.get("data", {})
        try:
            operation = DCCOperation(**operation_data)
            # Handle the operation asynchronously
            asyncio.create_task(handle_websocket_operation(websocket, operation))
        except Exception as e:
            await websocket.send_json({
                "type": "error",
                "message": f"Invalid operation data: {e}"
            })
    elif message_type == "custom_node_operation":
        operation_data = data.get("data", {})
        try:
            operation = CustomNodeOperation(**operation_data)
            asyncio.create_task(handle_websocket_custom_node_operation(websocket, operation))
        except Exception as e:
            await websocket.send_json({
                "type": "error",
                "message": f"Invalid custom node operation data: {e}"
            })
    elif message_type == "get_custom_nodes":
        # Return list of custom nodes
        if custom_node_executor:
            metadata = custom_node_executor.get_node_metadata()
            await websocket.send_json({
                "type": "custom_nodes_list",
                "data": {
                    "nodes": metadata,
                    "count": len(metadata),
                    "timestamp": datetime.now().isoformat()
                }
            })
        else:
            await websocket.send_json({
                "type": "custom_nodes_list",
                "data": {
                    "nodes": [],
                    "count": 0,
                    "error": "Custom node executor not initialized"
                }
            })

async def handle_websocket_operation(websocket: WebSocket, operation: DCCOperation):
    """Handle DCC operation via WebSocket with real-time progress updates."""
    try:
        # Add to active jobs
        active_jobs[operation.operation_id] = {
            "operation": operation.dict(),
            "status": "running",
            "start_time": datetime.now(),
            "progress": 0
        }

        # Send operation started message
        await websocket.send_json({
            "type": "operation_started",
            "operation_id": operation.operation_id,
            "timestamp": datetime.now().isoformat()
        })

        # Execute operation with progress callbacks
        async def progress_callback(progress: int, message: str):
            await websocket.send_json({
                "type": "operation_progress",
                "operation_id": operation.operation_id,
                "progress": progress,
                "message": message,
                "timestamp": datetime.now().isoformat()
            })
            active_jobs[operation.operation_id]["progress"] = progress

        result = await dcc_executor.execute_operation(operation, progress_callback)

        # Update job status
        active_jobs[operation.operation_id]["status"] = "completed"
        active_jobs[operation.operation_id]["result"] = result.dict()

        # Send completion message
        await websocket.send_json({
            "type": "operation_completed",
            "operation_id": operation.operation_id,
            "result": result.dict(),
            "timestamp": datetime.now().isoformat()
        })

    except Exception as e:
        # Update job status
        active_jobs[operation.operation_id]["status"] = "failed"
        active_jobs[operation.operation_id]["error"] = str(e)

        # Send error message
        await websocket.send_json({
            "type": "operation_failed",
            "operation_id": operation.operation_id,
            "error": str(e),
            "timestamp": datetime.now().isoformat()
        })


async def handle_websocket_custom_node_operation(websocket: WebSocket, operation: CustomNodeOperation):
    """Handle custom node operation via local WebSocket with real-time progress."""
    try:
        if not custom_node_executor:
            await websocket.send_json({
                "type": "operation_failed",
                "operation_id": operation.operation_id,
                "error": "Custom node executor not initialized",
                "timestamp": datetime.now().isoformat()
            })
            return

        # Send operation started message
        await websocket.send_json({
            "type": "operation_started",
            "operation_id": operation.operation_id,
            "node_type": operation.node_type,
            "timestamp": datetime.now().isoformat()
        })

        # Progress callback
        async def progress_callback(progress: float, message: str):
            await websocket.send_json({
                "type": "operation_progress",
                "operation_id": operation.operation_id,
                "progress": int(progress * 100),
                "message": message,
                "timestamp": datetime.now().isoformat()
            })

        # Execute the custom node
        result = await custom_node_executor.execute_node(
            node_type=operation.node_type,
            node_id=operation.node_id,
            properties=operation.properties,
            inputs=operation.inputs,
            context_data=operation.context,
            progress_callback=progress_callback
        )

        if result['success']:
            await websocket.send_json({
                "type": "operation_completed",
                "operation_id": operation.operation_id,
                "result": result,
                "timestamp": datetime.now().isoformat()
            })
        else:
            await websocket.send_json({
                "type": "operation_failed",
                "operation_id": operation.operation_id,
                "error": result.get('error', 'Unknown error'),
                "logs": result.get('logs', []),
                "timestamp": datetime.now().isoformat()
            })

    except Exception as e:
        await websocket.send_json({
            "type": "operation_failed",
            "operation_id": operation.operation_id,
            "error": str(e),
            "timestamp": datetime.now().isoformat()
        })


async def cleanup_job(operation_id: str, delay: int = 0):
    """Clean up completed job after delay."""
    if delay > 0:
        await asyncio.sleep(delay)

    if operation_id in active_jobs:
        del active_jobs[operation_id]
        logger.info(f"🧹 Cleaned up job: {operation_id}")

async def broadcast_to_clients(message: Dict):
    """Broadcast message to all connected WebSocket clients."""
    if not connected_clients:
        return

    disconnected_clients = []
    for client_id, websocket in connected_clients.items():
        try:
            await websocket.send_json(message)
        except Exception as e:
            logger.warning(f"Failed to send message to client {client_id}: {e}")
            disconnected_clients.append(client_id)

    # Clean up disconnected clients
    for client_id in disconnected_clients:
        if client_id in connected_clients:
            del connected_clients[client_id]

def run_agent(host: str = "127.0.0.1", port: int = 8001):
    """Run the Local DCC Agent server."""
    logger.info(f"[START] Starting Local DCC Agent on {host}:{port}")
    uvicorn.run(app, host=host, port=port, log_level="info")

if __name__ == "__main__":
    run_agent()