# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
GraphicalEditor routes - node-types, execute, workflows, runs, tasks, connections, browse.
"""

import asyncio
import json
import logging
import math
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from modules.auth import limiter, getRequestContext, RequestContext
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeHelpers import _applyFiltersAndSort

from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices
from modules.features.graphicalEditor.nodeRegistry import getNodeTypesForApi
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
from modules.workflows.automation2.executionEngine import executeGraph
from modules.workflows.automation2.runEnvelope import (
    default_run_envelope,
    merge_run_envelope,
    normalize_run_envelope,
)
from modules.features.graphicalEditor.entryPoints import find_invocation
from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor")

logger = logging.getLogger(__name__)


def _build_execute_run_envelope(
    body: Dict[str, Any],
    workflow: Optional[Dict[str, Any]],
    user_id: Optional[str],
    requestLang: Optional[str] = None,
) -> Dict[str, Any]:
    """Build normalized run envelope from POST /execute body."""
    if isinstance(body.get("runEnvelope"), dict):
        env = normalize_run_envelope(body["runEnvelope"], user_id=user_id)
        pl = body.get("payload")
        if isinstance(pl, dict):
            env = merge_run_envelope(env, {"payload": pl})
        return env

    entry_point_id = body.get("entryPointId")
    if entry_point_id:
        if not workflow:
            raise HTTPException(
                status_code=400,
                detail=routeApiMsg("entryPointId requires a saved workflow (workflowId must refer to a stored workflow)"),
            )
        inv = find_invocation(workflow, entry_point_id)
        if not inv:
            raise HTTPException(status_code=400, detail=routeApiMsg("entryPointId not found on workflow"))
        if not inv.get("enabled", True):
            raise HTTPException(status_code=400, detail=routeApiMsg("entry point is disabled"))
        kind = inv.get("kind", "manual")
        trig_map = {
            "manual": "manual",
            "form": "form",
            "schedule": "schedule",
            "always_on": "event",
            "email": "email",
            "webhook": "webhook",
            "api": "api",
            "event": "event",
        }
        trig = trig_map.get(kind, "manual")
        title = inv.get("title") or {}
        label = resolveText(title)
        base = default_run_envelope(
            trig,
            entry_point_id=inv.get("id"),
            entry_point_label=label or None,
        )
        pl = body.get("payload")
        if isinstance(pl, dict):
            base = merge_run_envelope(base, {"payload": pl})
        return normalize_run_envelope(base, user_id=user_id)

    env = normalize_run_envelope(None, user_id=user_id)
    pl = body.get("payload")
    if isinstance(pl, dict):
        env = merge_run_envelope(env, {"payload": pl})
    return env

router = APIRouter(
    prefix="/api/workflows",
    tags=["GraphicalEditor"],
    responses={404: {"description": "Not found"}, 403: {"description": "Forbidden"}},
)


def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
    """Validate user has access to the graphicalEditor feature instance. Returns mandateId."""
    from fastapi import HTTPException
    from modules.interfaces.interfaceDbApp import getRootInterface

    rootInterface = getRootInterface()
    instance = rootInterface.getFeatureInstance(instanceId)
    if not instance:
        raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found")
    featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId)
    if not featureAccess or not featureAccess.enabled:
        raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to this feature instance"))
    return str(instance.mandateId) if instance.mandateId else ""


@router.get("/{instanceId}/node-types")
@limiter.limit("60/minute")
def get_node_types(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    language: str = Query("en", description="Localization (en, de, fr)"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Return node types for the flow builder: static + I/O from methodDiscovery."""
    logger.info("graphicalEditor node-types request: instanceId=%s language=%s", instanceId, language)
    mandateId = _validateInstanceAccess(instanceId, context)
    services = getGraphicalEditorServices(
        context.user,
        mandateId=mandateId,
        featureInstanceId=instanceId,
    )
    result = getNodeTypesForApi(services, language=language)
    logger.info(
        "graphicalEditor node-types response: %d nodeTypes %d categories",
        len(result.get("nodeTypes", [])),
        len(result.get("categories", [])),
    )
    return result


@router.post("/{instanceId}/execute")
@limiter.limit("30/minute")
async def post_execute(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    body: dict = Body(..., description="{ workflowId?, graph: { nodes, connections } }"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Execute workflow graph. Body: { workflowId?, graph: { nodes, connections } }."""
    userId = str(context.user.id) if context.user else None
    logger.info(
        "graphicalEditor execute request: instanceId=%s userId=%s body_keys=%s",
        instanceId,
        userId,
        list(body.keys()),
    )
    mandateId = _validateInstanceAccess(instanceId, context)
    services = getGraphicalEditorServices(
        context.user,
        mandateId=mandateId,
        featureInstanceId=instanceId,
    )
    from modules.workflows.processing.shared.methodDiscovery import discoverMethods
    discoverMethods(services)

    graph = body.get("graph") or body
    workflowId = body.get("workflowId")
    req_nodes = graph.get("nodes") or []
    workflow_for_envelope: Optional[Dict[str, Any]] = None
    if workflowId and not str(workflowId).startswith("transient-"):
        iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
        workflow_for_envelope = iface.getWorkflow(workflowId)
    if workflowId and len(req_nodes) == 0:
        iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
        wf = iface.getWorkflow(workflowId)
        if wf and wf.get("graph"):
            graph = wf["graph"]
            logger.info("graphicalEditor execute: loaded graph from workflow %s", workflowId)
            workflow_for_envelope = wf
    if not workflowId:
        import uuid
        workflowId = f"transient-{uuid.uuid4().hex[:12]}"
        logger.info("graphicalEditor execute: using transient workflowId=%s", workflowId)
    nodes_count = len(graph.get("nodes") or [])
    connections_count = len(graph.get("connections") or [])
    logger.info(
        "graphicalEditor execute: graph nodes=%d connections=%d workflowId=%s mandateId=%s",
        nodes_count,
        connections_count,
        workflowId,
        mandateId,
    )
    run_env = _build_execute_run_envelope(
        body,
        workflow_for_envelope,
        userId,
        getattr(context.user, "language", None) if context.user else None,
    )

    _wfLabel = None
    if workflow_for_envelope:
        _wfLabel = workflow_for_envelope.get("label") if isinstance(workflow_for_envelope, dict) else getattr(workflow_for_envelope, "label", None)

    ge_interface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    result = await executeGraph(
        graph=graph,
        services=services,
        workflowId=workflowId,
        instanceId=instanceId,
        userId=userId,
        mandateId=mandateId,
        automation2_interface=ge_interface,
        run_envelope=run_env,
        label=_wfLabel,
    )
    logger.info(
        "graphicalEditor execute result: success=%s error=%s nodeOutputs_keys=%s failedNode=%s paused=%s",
        result.get("success"),
        result.get("error"),
        list(result.get("nodeOutputs", {}).keys()) if result.get("nodeOutputs") else [],
        result.get("failedNode"),
        result.get("paused"),
    )
    return result


# -------------------------------------------------------------------------
# Run Tracing SSE Stream
# -------------------------------------------------------------------------


@router.get("/{instanceId}/runs/{runId}/stream")
async def get_run_stream(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    runId: str = Path(..., description="Run ID"),
    context: RequestContext = Depends(getRequestContext),
):
    """SSE stream for live step-log updates during a workflow run."""
    _validateInstanceAccess(instanceId, context)

    from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager
    sseEventManager = get_event_manager()
    queueId = f"run-trace-{runId}"
    sseEventManager.create_queue(queueId)

    async def _sseGenerator():
        queue = sseEventManager.get_queue(queueId)
        if not queue:
            return
        while True:
            try:
                event = await asyncio.wait_for(queue.get(), timeout=30)
            except asyncio.TimeoutError:
                yield "data: {\"type\": \"keepalive\"}\n\n"
                continue
            if event is None:
                break
            payload = event.get("data", event) if isinstance(event, dict) else event
            yield f"data: {json.dumps(payload, default=str)}\n\n"
            eventType = payload.get("type", "") if isinstance(payload, dict) else ""
            if eventType in ("run_complete", "run_failed"):
                break
        await sseEventManager.cleanup(queueId, delay=10)

    return StreamingResponse(
        _sseGenerator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",
        },
    )


# -------------------------------------------------------------------------
# Versions (AutoVersion Lifecycle)
# -------------------------------------------------------------------------


@router.get("/{instanceId}/workflows/{workflowId}/versions")
@limiter.limit("60/minute")
def get_versions(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    workflowId: str = Path(..., description="Workflow ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """List all versions for a workflow."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    versions = iface.getVersions(workflowId)
    return {"versions": versions}


@router.post("/{instanceId}/workflows/{workflowId}/versions/draft")
@limiter.limit("30/minute")
def create_draft_version(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    workflowId: str = Path(..., description="Workflow ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Create a new draft version from the workflow's current graph."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    version = iface.createDraftVersion(workflowId)
    if not version:
        raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
    return version


@router.post("/{instanceId}/versions/{versionId}/publish")
@limiter.limit("30/minute")
def publish_version(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    versionId: str = Path(..., description="Version ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Publish a draft version. Archives the previously published version."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    userId = str(context.user.id) if context.user else None
    version = iface.publishVersion(versionId, userId=userId)
    if not version:
        raise HTTPException(status_code=400, detail=routeApiMsg("Version not found or not in draft status"))
    return version


@router.post("/{instanceId}/versions/{versionId}/unpublish")
@limiter.limit("30/minute")
def unpublish_version(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    versionId: str = Path(..., description="Version ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Unpublish a version (revert to draft)."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    version = iface.unpublishVersion(versionId)
    if not version:
        raise HTTPException(status_code=400, detail=routeApiMsg("Version not found or not published"))
    return version


@router.post("/{instanceId}/versions/{versionId}/archive")
@limiter.limit("30/minute")
def archive_version(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    versionId: str = Path(..., description="Version ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Archive a version."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    version = iface.archiveVersion(versionId)
    if not version:
        raise HTTPException(status_code=404, detail=routeApiMsg("Version not found"))
    return version


# -------------------------------------------------------------------------
# Templates
# -------------------------------------------------------------------------


@router.get("/{instanceId}/templates")
@limiter.limit("60/minute")
def get_templates(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    scope: Optional[str] = Query(None, description="Filter by scope: user, instance, mandate, system"),
    pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
    context: RequestContext = Depends(getRequestContext),
):
    """List workflow templates with optional pagination."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    templates = iface.getTemplates(scope=scope)

    paginationParams = None
    if pagination:
        try:
            paginationDict = json.loads(pagination)
            if paginationDict:
                paginationDict = normalize_pagination_dict(paginationDict)
                paginationParams = PaginationParams(**paginationDict)
        except (json.JSONDecodeError, ValueError) as e:
            raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")

    if paginationParams:
        filtered = _applyFiltersAndSort(templates, paginationParams)
        totalItems = len(filtered)
        totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
        startIdx = (paginationParams.page - 1) * paginationParams.pageSize
        endIdx = startIdx + paginationParams.pageSize
        return {
            "items": filtered[startIdx:endIdx],
            "pagination": PaginationMetadata(
                currentPage=paginationParams.page, pageSize=paginationParams.pageSize,
                totalItems=totalItems, totalPages=totalPages,
                sort=paginationParams.sort, filters=paginationParams.filters,
            ).model_dump(),
        }
    return {"templates": templates}


@router.post("/{instanceId}/templates/from-workflow")
@limiter.limit("30/minute")
def create_template_from_workflow(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    body: dict = Body(..., description="{ workflowId, scope? }"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Create a template from an existing workflow."""
    mandateId = _validateInstanceAccess(instanceId, context)
    workflowId = body.get("workflowId")
    scope = body.get("scope", "user")
    if not workflowId:
        raise HTTPException(status_code=400, detail=routeApiMsg("workflowId required"))
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    template = iface.createTemplateFromWorkflow(workflowId, scope=scope)
    if not template:
        raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
    return template


@router.post("/{instanceId}/templates/{templateId}/copy")
@limiter.limit("30/minute")
def copy_template(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    templateId: str = Path(..., description="Template ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Copy a template to a new user-owned workflow."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    workflow = iface.copyTemplateToUser(templateId)
    if not workflow:
        raise HTTPException(status_code=404, detail=routeApiMsg("Template not found"))
    return workflow


@router.post("/{instanceId}/templates/{templateId}/share")
@limiter.limit("30/minute")
def share_template(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    templateId: str = Path(..., description="Template ID"),
    body: dict = Body(..., description="{ scope }"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Share a template by changing its scope."""
    mandateId = _validateInstanceAccess(instanceId, context)
    scope = body.get("scope")
    if not scope or scope not in ("user", "instance", "mandate", "system"):
        raise HTTPException(status_code=400, detail=routeApiMsg("scope must be user, instance, mandate, or system"))
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    template = iface.shareTemplate(templateId, scope=scope)
    if not template:
        raise HTTPException(status_code=404, detail=routeApiMsg("Template not found"))
    return template


# -------------------------------------------------------------------------
# AI Chat for Editor
# -------------------------------------------------------------------------


@router.post("/{instanceId}/{workflowId}/chat/stream")
@limiter.limit("30/minute")
async def post_editor_chat(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    workflowId: str = Path(..., description="Workflow ID"),
    body: dict = Body(..., description="{ message, conversationHistory?, userLanguage? }"),
    context: RequestContext = Depends(getRequestContext),
):
    """AI chat endpoint for the editor with SSE streaming. Uses workflow tools to mutate the graph."""
    mandateId = _validateInstanceAccess(instanceId, context)
    message = body.get("message", "")
    if not message:
        raise HTTPException(status_code=400, detail=routeApiMsg("message required"))

    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    wf = iface.getWorkflow(workflowId)
    if not wf:
        raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))

    userLanguage = body.get("userLanguage", "de")
    conversationHistory = body.get("conversationHistory") or []
    fileIds = body.get("fileIds") or []
    dataSourceIds = body.get("dataSourceIds") or []
    featureDataSourceIds = body.get("featureDataSourceIds") or []

    from modules.serviceCenter.core.serviceStreaming import get_event_manager
    sseEventManager = get_event_manager()
    queueId = f"ge-chat-{workflowId}-{id(request)}"
    sseEventManager.create_queue(queueId)

    agentTask = asyncio.ensure_future(
        _runEditorAgent(
            workflowId=workflowId,
            queueId=queueId,
            prompt=message,
            instanceId=instanceId,
            user=context.user,
            mandateId=mandateId,
            sseEventManager=sseEventManager,
            userLanguage=userLanguage,
            conversationHistory=conversationHistory,
            fileIds=fileIds,
            dataSourceIds=dataSourceIds,
            featureDataSourceIds=featureDataSourceIds,
        )
    )
    sseEventManager.register_agent_task(queueId, agentTask)

    async def _sseGenerator():
        queue = sseEventManager.get_queue(queueId)
        if not queue:
            return
        while True:
            try:
                event = await asyncio.wait_for(queue.get(), timeout=120)
            except asyncio.TimeoutError:
                yield "data: {\"type\": \"keepalive\"}\n\n"
                continue
            if event is None:
                break
            ssePayload = event.get("data", event) if isinstance(event, dict) else event
            yield f"data: {json.dumps(ssePayload, default=str)}\n\n"
            eventType = ssePayload.get("type", "") if isinstance(ssePayload, dict) else ""
            if eventType in ("complete", "error", "stopped"):
                break
        await sseEventManager.cleanup(queueId, delay=30)

    return StreamingResponse(
        _sseGenerator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",
        },
    )


async def _runEditorAgent(
    workflowId: str,
    queueId: str,
    prompt: str,
    instanceId: str,
    user=None,
    mandateId: str = "",
    sseEventManager=None,
    userLanguage: str = "de",
    conversationHistory: List[Dict[str, Any]] = None,
    fileIds: List[str] = None,
    dataSourceIds: List[str] = None,
    featureDataSourceIds: List[str] = None,
):
    """Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue."""
    try:
        from modules.serviceCenter import getService
        from modules.serviceCenter.context import ServiceCenterContext
        from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum

        ctx = ServiceCenterContext(
            user=user,
            mandate_id=mandateId,
            feature_instance_id=instanceId,
            workflow_id=workflowId,
            feature_code="graphicalEditor",
        )
        agentService = getService("agent", ctx)

        systemPrompt = (
            "You are a workflow editor assistant. The user describes changes to a workflow graph. "
            "Use the available workflow tools (readWorkflowGraph, addNode, removeNode, connectNodes, "
            "setNodeParameter, listAvailableNodeTypes, validateGraph) to modify the graph. "
            "Always read the current graph first before making changes. "
            "Respond concisely and confirm what you changed."
        )

        enrichedPrompt = prompt
        if dataSourceIds:
            from modules.features.workspace.routeFeatureWorkspace import _buildDataSourceContext
            chatSvc = getService("chat", ctx)
            dsInfo = _buildDataSourceContext(chatSvc, dataSourceIds)
            if dsInfo:
                enrichedPrompt = f"{prompt}\n\n[Active Data Sources]\n{dsInfo}"

        if featureDataSourceIds:
            from modules.features.workspace.routeFeatureWorkspace import _buildFeatureDataSourceContext
            fdsInfo = _buildFeatureDataSourceContext(featureDataSourceIds)
            if fdsInfo:
                enrichedPrompt = f"{enrichedPrompt}\n\n[Attached Feature Data Sources]\n{fdsInfo}"

        accumulatedText = ""

        async for event in agentService.runAgent(
            prompt=enrichedPrompt,
            fileIds=fileIds or [],
            workflowId=workflowId,
            userLanguage=userLanguage,
            conversationHistory=conversationHistory or [],
            toolSet="core",
            additionalTools=None,
            systemPromptOverride=systemPrompt,
        ):
            if sseEventManager.is_cancelled(queueId):
                logger.info("Editor chat agent cancelled for workflow %s", workflowId)
                break

            if event.type == AgentEventTypeEnum.CHUNK and event.content:
                accumulatedText += event.content

            sseEvent = {
                "type": event.type.value if hasattr(event.type, "value") else event.type,
                "workflowId": workflowId,
            }
            if event.content:
                sseEvent["content"] = event.content
            if event.data:
                sseEvent["item"] = event.data

            await sseEventManager.emit_event(queueId, sseEvent["type"], sseEvent)

            if event.type in (AgentEventTypeEnum.FINAL, AgentEventTypeEnum.ERROR):
                break

        await sseEventManager.emit_event(queueId, "complete", {
            "type": "complete",
            "workflowId": workflowId,
        })

    except asyncio.CancelledError:
        logger.info("Editor chat agent task cancelled for workflow %s", workflowId)
        await sseEventManager.emit_event(queueId, "stopped", {
            "type": "stopped",
            "workflowId": workflowId,
        })

    except Exception as e:
        logger.error("Editor chat agent error: %s", e, exc_info=True)
        await sseEventManager.emit_event(queueId, "error", {
            "type": "error",
            "content": str(e),
            "workflowId": workflowId,
        })
    finally:
        sseEventManager._unregister_agent_task(queueId)


# -------------------------------------------------------------------------
# Connections and Browse (for Email/SharePoint node config)
# -------------------------------------------------------------------------


def _buildResolverDbInterface(chatService):
    """Build a DB adapter that ConnectorResolver can use to load UserConnections."""
    class _ResolverDbAdapter:
        def __init__(self, appInterface):
            self._app = appInterface

        def getUserConnection(self, connectionId: str):
            if hasattr(self._app, "getUserConnectionById"):
                return self._app.getUserConnectionById(connectionId)
            return None

    appIf = getattr(chatService, "interfaceDbApp", None)
    if appIf:
        return _ResolverDbAdapter(appIf)
    return getattr(chatService, "interfaceDbComponent", None)


@router.get("/{instanceId}/connections")
@limiter.limit("300/minute")
def list_connections(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Return the user's active connections (UserConnections) for Email/SharePoint node config."""
    mandateId = _validateInstanceAccess(instanceId, context)
    from modules.serviceCenter import getService
    from modules.serviceCenter.context import ServiceCenterContext
    ctx = ServiceCenterContext(
        user=context.user,
        mandate_id=str(context.mandateId) if context.mandateId else mandateId,
        feature_instance_id=instanceId,
    )
    chatService = getService("chat", ctx)
    connections = chatService.getUserConnections()
    items = []
    for c in connections or []:
        conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {})
        authority = conn.get("authority")
        if hasattr(authority, "value"):
            authority = authority.value
        status = conn.get("status")
        if hasattr(status, "value"):
            status = status.value
        items.append({
            "id": conn.get("id"),
            "authority": authority,
            "externalUsername": conn.get("externalUsername"),
            "externalEmail": conn.get("externalEmail"),
            "status": status,
        })
    return {"connections": items}


@router.get("/{instanceId}/connections/{connectionId}/services")
@limiter.limit("120/minute")
async def list_connection_services(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    connectionId: str = Path(..., description="Connection ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Return the available services for a specific UserConnection."""
    mandateId = _validateInstanceAccess(instanceId, context)
    try:
        from modules.connectors.connectorResolver import ConnectorResolver
        from modules.serviceCenter import getService as getSvc
        from modules.serviceCenter.context import ServiceCenterContext
        ctx = ServiceCenterContext(
            user=context.user,
            mandate_id=str(context.mandateId) if context.mandateId else mandateId,
            feature_instance_id=instanceId,
        )
        chatService = getSvc("chat", ctx)
        securityService = getSvc("security", ctx)
        dbInterface = _buildResolverDbInterface(chatService)
        resolver = ConnectorResolver(securityService, dbInterface)
        provider = await resolver.resolve(connectionId)
        services = provider.getAvailableServices()
        _serviceLabels = {
            "sharepoint": "SharePoint",
            "clickup": "ClickUp",
            "outlook": "Outlook",
            "teams": "Teams",
            "onedrive": "OneDrive",
            "drive": "Google Drive",
            "gmail": "Gmail",
            "files": "Files (FTP)",
        }
        _serviceIcons = {
            "sharepoint": "sharepoint",
            "clickup": "folder",
            "outlook": "mail",
            "teams": "chat",
            "onedrive": "cloud",
            "drive": "cloud",
            "gmail": "mail",
            "files": "folder",
        }
        items = [
            {"service": s, "label": _serviceLabels.get(s, s), "icon": _serviceIcons.get(s, "folder")}
            for s in services
        ]
        return {"services": items}
    except Exception as e:
        logger.error(f"Error listing services for connection {connectionId}: {e}")
        return JSONResponse({"services": [], "error": str(e)}, status_code=400)


@router.get("/{instanceId}/connections/{connectionId}/browse")
@limiter.limit("300/minute")
async def browse_connection_service(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    connectionId: str = Path(..., description="Connection ID"),
    service: str = Query(..., description="Service name (e.g. sharepoint, onedrive, outlook)"),
    path: str = Query("/", description="Path within the service to browse"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Browse folders/items within a connection's service at a given path."""
    mandateId = _validateInstanceAccess(instanceId, context)
    try:
        from modules.connectors.connectorResolver import ConnectorResolver
        from modules.serviceCenter import getService as getSvc
        from modules.serviceCenter.context import ServiceCenterContext
        ctx = ServiceCenterContext(
            user=context.user,
            mandate_id=str(context.mandateId) if context.mandateId else mandateId,
            feature_instance_id=instanceId,
        )
        chatService = getSvc("chat", ctx)
        securityService = getSvc("security", ctx)
        dbInterface = _buildResolverDbInterface(chatService)
        resolver = ConnectorResolver(securityService, dbInterface)
        adapter = await resolver.resolveService(connectionId, service)
        entries = await adapter.browse(path, filter=None)
        items = []
        for entry in (entries or []):
            items.append({
                "name": entry.name,
                "path": entry.path,
                "isFolder": entry.isFolder,
                "size": entry.size,
                "mimeType": entry.mimeType,
                "metadata": entry.metadata if hasattr(entry, "metadata") else {},
            })
        return {"items": items, "path": path, "service": service}
    except Exception as e:
        logger.error(f"Error browsing {service} for connection {connectionId} at '{path}': {e}")
        return JSONResponse({"items": [], "error": str(e)}, status_code=400)


# -------------------------------------------------------------------------
# Workflow CRUD
# -------------------------------------------------------------------------


def _get_node_label_from_graph(graph: dict, nodeId: str) -> str:
    """Extract human-readable label for a node from graph."""
    if not graph or not nodeId:
        return nodeId or ""
    nodes = graph.get("nodes") or []
    for n in nodes:
        if n.get("id") == nodeId:
            params = n.get("parameters") or {}
            config = params.get("config") or {}
            if isinstance(config, dict):
                label = config.get("title") or config.get("label")
            else:
                label = None
            return (
                n.get("title")
                or label
                or params.get("title")
                or params.get("label")
                or n.get("type", "")
                or nodeId
            )
    return nodeId or ""


@router.get("/{instanceId}/workflows")
@limiter.limit("60/minute")
def get_workflows(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    active: Optional[bool] = Query(None, description="Filter by active: true|false"),
    pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
    context: RequestContext = Depends(getRequestContext),
):
    """List all workflows for this feature instance."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    items = iface.getWorkflows(active=active)
    enriched = []
    for wf in items:
        wf_id = wf.get("id")
        runs = iface.getRunsByWorkflow(wf_id) if wf_id else []
        run_count = len(runs)
        active_run = None
        last_started_at = None
        for r in runs:
            ts = r.get("sysCreatedAt")
            if ts and (last_started_at is None or ts > last_started_at):
                last_started_at = ts
            if r.get("status") in ("running", "paused"):
                active_run = r
        stuck_at_node_id = active_run.get("currentNodeId") if active_run else None
        stuck_at_node_label = ""
        if stuck_at_node_id and wf.get("graph"):
            stuck_at_node_label = _get_node_label_from_graph(wf["graph"], stuck_at_node_id)
        enriched.append({
            **wf,
            "runCount": run_count,
            "isRunning": active_run is not None,
            "runStatus": active_run.get("status") if active_run else None,
            "stuckAtNodeId": stuck_at_node_id,
            "stuckAtNodeLabel": stuck_at_node_label or stuck_at_node_id or "",
            "createdAt": wf.get("sysCreatedAt"),
            "lastStartedAt": last_started_at,
        })

    paginationParams = None
    if pagination:
        try:
            paginationDict = json.loads(pagination)
            if paginationDict:
                paginationDict = normalize_pagination_dict(paginationDict)
                paginationParams = PaginationParams(**paginationDict)
        except (json.JSONDecodeError, ValueError) as e:
            raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")

    if paginationParams:
        filtered = _applyFiltersAndSort(enriched, paginationParams)
        totalItems = len(filtered)
        totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
        startIdx = (paginationParams.page - 1) * paginationParams.pageSize
        endIdx = startIdx + paginationParams.pageSize
        return {
            "items": filtered[startIdx:endIdx],
            "pagination": PaginationMetadata(
                currentPage=paginationParams.page, pageSize=paginationParams.pageSize,
                totalItems=totalItems, totalPages=totalPages,
                sort=paginationParams.sort, filters=paginationParams.filters,
            ).model_dump(),
        }
    return {"workflows": enriched}


@router.get("/{instanceId}/workflows/{workflowId}")
@limiter.limit("60/minute")
def get_workflow(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    workflowId: str = Path(..., description="Workflow ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Get a single workflow by ID."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    wf = iface.getWorkflow(workflowId)
    if not wf:
        raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
    return wf


@router.post("/{instanceId}/workflows")
@limiter.limit("30/minute")
def create_workflow(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    body: dict = Body(..., description="{ label, graph }"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Create a new workflow."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    created = iface.createWorkflow(body)
    return created


@router.put("/{instanceId}/workflows/{workflowId}")
@limiter.limit("30/minute")
def update_workflow(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    workflowId: str = Path(..., description="Workflow ID"),
    body: dict = Body(..., description="{ label?, graph? }"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Update a workflow."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    updated = iface.updateWorkflow(workflowId, body)
    if not updated:
        raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
    return updated


@router.delete("/{instanceId}/workflows/{workflowId}")
@limiter.limit("30/minute")
def delete_workflow(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    workflowId: str = Path(..., description="Workflow ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Delete a workflow."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    if not iface.deleteWorkflow(workflowId):
        raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
    return {"success": True}


# -------------------------------------------------------------------------
# Runs and Resume
# -------------------------------------------------------------------------


@router.get("/{instanceId}/runs/completed")
@limiter.limit("60/minute")
def get_completed_runs(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    limit: int = Query(20, ge=1, le=50),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Get recently completed runs with output."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    runs = iface.getRecentCompletedRuns(limit=limit)
    return {"runs": runs}


@router.get("/{instanceId}/workflows/{workflowId}/runs")
@limiter.limit("60/minute")
def get_workflow_runs(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    workflowId: str = Path(..., description="Workflow ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Get runs for a workflow."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    if not iface.getWorkflow(workflowId):
        raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
    runs = iface.getRunsByWorkflow(workflowId)
    return {"runs": runs}


@router.get("/{instanceId}/runs/{runId}/steps")
@limiter.limit("60/minute")
def get_run_steps(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    runId: str = Path(..., description="Run ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Get step logs for a run (AutoStepLog entries)."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoStepLog
    if not iface.db._ensureTableExists(AutoStepLog):
        return {"steps": []}
    records = iface.db.getRecordset(AutoStepLog, recordFilter={"runId": runId})
    steps = [dict(r) for r in records] if records else []
    steps.sort(key=lambda s: s.get("startedAt") or 0)
    return {"steps": steps}


# -------------------------------------------------------------------------
# Tasks
# -------------------------------------------------------------------------


@router.get("/{instanceId}/tasks")
@limiter.limit("60/minute")
def get_tasks(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    workflowId: str = Query(None, description="Filter by workflow ID"),
    status: str = Query(None, description="Filter: pending, completed, rejected"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Get tasks assigned to current user."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    assigneeId = str(context.user.id) if context.user else None
    items = iface.getTasks(workflowId=workflowId, status=status, assigneeId=assigneeId)
    workflows = {w["id"]: w for w in iface.getWorkflows()}
    enriched = []
    for t in items:
        wf = workflows.get(t.get("workflowId") or "")
        enriched.append({
            **t,
            "workflowLabel": wf.get("label", t.get("workflowId", "")) if wf else t.get("workflowId", ""),
            "createdAt": t.get("sysCreatedAt"),
        })
    return {"tasks": enriched}


@router.post("/{instanceId}/tasks/{taskId}/complete")
@limiter.limit("30/minute")
async def complete_task(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    taskId: str = Path(..., description="Task ID"),
    body: dict = Body(..., description="{ result }"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Complete a task and resume the run."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
    task = iface.getTask(taskId)
    if not task:
        raise HTTPException(status_code=404, detail=routeApiMsg("Task not found"))
    runId = task.get("runId")
    result = body.get("result")
    if result is None:
        raise HTTPException(status_code=400, detail=routeApiMsg("result required"))
    run = iface.getRun(runId)
    if not run:
        raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
    if task.get("status") != "pending":
        raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed"))
    iface.updateTask(taskId, status="completed", result=result)
    nodeId = task.get("nodeId")
    nodeOutputs = dict(run.get("nodeOutputs") or {})
    nodeOutputs[nodeId] = result
    workflowId = run.get("workflowId")
    wf = iface.getWorkflow(workflowId) if workflowId else None
    if not wf or not wf.get("graph"):
        raise HTTPException(status_code=400, detail=routeApiMsg("Workflow graph not found"))
    graph = wf["graph"]
    services = getGraphicalEditorServices(context.user, mandateId=mandateId, featureInstanceId=instanceId)
    return await executeGraph(
        graph=graph,
        services=services,
        workflowId=workflowId,
        instanceId=instanceId,
        userId=str(context.user.id) if context.user else None,
        mandateId=mandateId,
        automation2_interface=iface,
        initialNodeOutputs=nodeOutputs,
        startAfterNodeId=nodeId,
        runId=runId,
    )


# -------------------------------------------------------------------------
# Monitoring / Metrics
# -------------------------------------------------------------------------


@router.get("/{instanceId}/metrics")
@limiter.limit("60/minute")
def get_metrics(
    request: Request,
    instanceId: str = Path(..., description="Feature instance ID"),
    context: RequestContext = Depends(getRequestContext),
) -> dict:
    """Aggregated metrics for the monitoring dashboard."""
    mandateId = _validateInstanceAccess(instanceId, context)
    iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)

    from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
        AutoWorkflow, AutoRun, AutoStepLog, AutoTask,
    )

    workflows = iface.db.getRecordset(AutoWorkflow, recordFilter={
        "mandateId": mandateId, "featureInstanceId": instanceId, "isTemplate": False,
    }) or []
    runs = iface.db.getRecordset(AutoRun, recordFilter={
        "workflowId": {"$in": [w.get("id") for w in workflows]} if workflows else "__none__",
    }) or []
    tasks = iface.db.getRecordset(AutoTask, recordFilter={
        "workflowId": {"$in": [w.get("id") for w in workflows]} if workflows else "__none__",
    }) or []

    runsByStatus = {}
    totalTokens = 0
    totalCredits = 0.0
    for r in runs:
        s = r.get("status", "unknown")
        runsByStatus[s] = runsByStatus.get(s, 0) + 1
        totalTokens += r.get("costTokens", 0) or 0
        totalCredits += r.get("costCredits", 0.0) or 0.0

    tasksByStatus = {}
    for t in tasks:
        s = t.get("status", "unknown")
        tasksByStatus[s] = tasksByStatus.get(s, 0) + 1

    return {
        "workflowCount": len(workflows),
        "activeWorkflows": sum(1 for w in workflows if w.get("active")),
        "totalRuns": len(runs),
        "runsByStatus": runsByStatus,
        "totalTasks": len(tasks),
        "tasksByStatus": tasksByStatus,
        "totalTokens": totalTokens,
        "totalCredits": round(totalCredits, 4),
    }
