# Copyright (c) 2025 Patrick Motsch
# Action node executor - maps ai.*, email.*, sharepoint.* to method actions via ActionExecutor.

import logging
from typing import Dict, Any, List, Optional

logger = logging.getLogger(__name__)


def _getNodeDefinition(nodeType: str) -> Optional[Dict[str, Any]]:
    """Get node definition by type id for _method, _action, _paramMap."""
    from modules.features.automation2.nodeDefinitions import STATIC_NODE_TYPES
    for node in STATIC_NODE_TYPES:
        if node.get("id") == nodeType:
            return node
    return None


def _resolveConnectionIdToReference(chatService, connectionId: str, services=None) -> Optional[str]:
    """
    Resolve connectionId (UserConnection.id) to connectionReference format.
    connectionReference format: connection:{authority}:{externalUsername}
    Falls back to interfaceDbApp.getUserConnectionById when chatService resolution fails.
    """
    if not connectionId:
        return None
    # Already in reference format
    if isinstance(connectionId, str) and connectionId.startswith("connection:"):
        return connectionId
    # Try chatService first
    if chatService:
        try:
            connections = chatService.getUserConnections()
            for c in connections or []:
                conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {})
                if str(conn.get("id")) == str(connectionId):
                    authority = conn.get("authority")
                    if hasattr(authority, "value"):
                        authority = authority.value
                    username = conn.get("externalUsername", "")
                    return f"connection:{authority}:{username}"
        except Exception as e:
            logger.debug("_resolveConnectionIdToReference chatService: %s", e)
    # Fallback: interfaceDbApp.getUserConnectionById (automation2 may not have chat.getUserConnections)
    app = getattr(services, "interfaceDbApp", None) if services else None
    if app and hasattr(app, "getUserConnectionById"):
        try:
            conn = app.getUserConnectionById(str(connectionId))
            if conn:
                authority = getattr(conn, "authority", None)
                if hasattr(authority, "value"):
                    authority = authority.value
                else:
                    authority = str(authority) if authority else "outlook"
                username = getattr(conn, "externalUsername", "") or ""
                return f"connection:{authority}:{username}"
        except Exception as e:
            logger.debug("_resolveConnectionIdToReference getUserConnectionById: %s", e)
    return None


def _extractEmailContentFromUpstream(inp: Any) -> Optional[Dict[str, Any]]:
    """
    Extract {subject, body, to} from upstream node output (e.g. AI node returning JSON).
    Expects JSON like {"subject": "...", "body": "...", "to": "..."} in documentData.
    """
    if not inp:
        return None
    import json
    docs = inp.get("documents", inp.get("documentList", [])) if isinstance(inp, dict) else []
    if not docs:
        return None
    doc = docs[0] if isinstance(docs, list) else docs
    raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None)
    if not raw:
        return None
    try:
        data = json.loads(raw) if isinstance(raw, str) else raw
        if isinstance(data, dict) and data.get("subject") and data.get("body"):
            return {
                "subject": str(data.get("subject", "")),
                "body": str(data.get("body", "")),
                "to": data.get("to"),
            }
    except (json.JSONDecodeError, TypeError):
        pass
    return None


def _extractContextFromUpstream(inp: Any) -> Optional[str]:
    """
    Extract plain text context from upstream node output (e.g. AI node returning txt).
    Use when _extractEmailContentFromUpstream returns None – the generated document content
    (email body, summary, etc.) should be passed as context to email.draftEmail.
    """
    if not inp:
        return None
    docs = None
    if isinstance(inp, dict):
        docs = inp.get("documents") or inp.get("documentList")
        if not docs and isinstance(inp.get("data"), dict):
            docs = inp.get("data", {}).get("documents")
    if not docs or not isinstance(docs, (list, tuple)):
        return None
    doc = docs[0] if docs else None
    if not doc:
        return None
    raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") or doc.get("content") if isinstance(doc, dict) else None)
    if not raw:
        return None
    if isinstance(raw, bytes):
        return raw.decode("utf-8", errors="replace").strip()
    s = str(raw).strip()
    return s if s else None


def _gatherAttachmentDocumentsFromUpstream(
    nodeId: str,
    inputSources: Dict[str, Dict[int, tuple]],
    nodeOutputs: Dict[str, Any],
    orderedNodes: List[Dict],
    visited: Optional[set] = None,
) -> List[Any]:
    """
    Walk upstream from nodeId through AI nodes to collect file documents (e.g. from sharepoint.downloadFile).
    Used when email.draftEmail has AI upstream – attachments come from file nodes, not AI output.
    """
    visited = visited or set()
    if nodeId in visited:
        return []
    visited.add(nodeId)
    docs = []
    src = inputSources.get(nodeId, {}).get(0)
    if not src:
        return []
    srcId, _ = src
    srcNode = next((n for n in (orderedNodes or []) if n.get("id") == srcId), None)
    srcType = (srcNode or {}).get("type", "")
    out = nodeOutputs.get(srcId)

    if srcType in ("sharepoint.downloadFile", "sharepoint.readFile"):
        if isinstance(out, dict):
            for d in out.get("documents") or out.get("documentList") or []:
                if isinstance(d, dict) and (d.get("documentData") or (d.get("validationMetadata") or {}).get("fileId")):
                    docs.append(d)
                elif hasattr(d, "documentData") or (getattr(d, "validationMetadata", None) or {}).get("fileId"):
                    docs.append(d.model_dump() if hasattr(d, "model_dump") else d)
    elif srcType.startswith("ai."):
        docs.extend(
            _gatherAttachmentDocumentsFromUpstream(srcId, inputSources, nodeOutputs, orderedNodes, visited)
        )
    return docs


def _getIncomingEmailFromUpstream(
    nodeId: str,
    inputSources: Dict[str, Dict[int, tuple]],
    nodeOutputs: Dict[str, Any],
    orderedNodes: List[Dict],
) -> Optional[tuple]:
    """
    Walk upstream from draftEmail to find email.checkEmail/searchEmail and return (context, documentList).
    context = formatted incoming email(s) for composeAndDraftEmail.
    documentList = documents from the email node for attachment/context.
    """
    src = inputSources.get(nodeId, {}).get(0)
    if not src:
        return None
    srcId, _ = src
    srcNode = next((n for n in (orderedNodes or []) if n.get("id") == srcId), None)
    srcType = (srcNode or {}).get("type", "")

    # Direct connection to email node
    if srcType in ("email.checkEmail", "email.searchEmail"):
        out = nodeOutputs.get(srcId)
        return _formatEmailOutputAsContext(out)

    # Connected via AI node: walk one more step to email source
    if srcType.startswith("ai."):
        src2 = inputSources.get(srcId, {}).get(0)
        if not src2:
            return None
        emailNodeId, _ = src2
        emailNode = next((n for n in (orderedNodes or []) if n.get("id") == emailNodeId), None)
        if (emailNode or {}).get("type") in ("email.checkEmail", "email.searchEmail"):
            out = nodeOutputs.get(emailNodeId)
            return _formatEmailOutputAsContext(out)
    return None


def _formatEmailOutputAsContext(out: Any) -> Optional[tuple]:
    """Format email node output as (context, documentList, reply_to) for composeAndDraftEmail.
    reply_to = sender address of first email (recipient for the reply).
    """
    if not out:
        return None
    docs = out.get("documents", out.get("documentList", [])) if isinstance(out, dict) else []
    if not docs:
        return None
    doc = docs[0] if isinstance(docs, list) else docs
    raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None)
    if not raw:
        return None
    import json
    try:
        data = json.loads(raw) if isinstance(raw, str) else raw
    except (json.JSONDecodeError, TypeError):
        return None
    if not isinstance(data, dict):
        return None
    # readEmails: data.emails.emails | searchEmails: data.searchResults.results
    emails_data = data.get("emails") or {}
    emails_list = emails_data.get("emails", []) if isinstance(emails_data, dict) else []
    if not emails_list:
        search_results = data.get("searchResults") or {}
        emails_list = search_results.get("results", []) if isinstance(search_results, dict) else []
    if not emails_list:
        return None
    reply_to = None
    parts = ["Reply to the following email(s):", ""]
    for i, em in enumerate(emails_list[:5]):  # max 5
        if not isinstance(em, dict):
            continue
        fr = em.get("from", em.get("sender", {}))
        addr = fr.get("emailAddress", {}) if isinstance(fr, dict) else {}
        from_str = addr.get("address", "") or addr.get("name", "")
        if from_str and not reply_to:
            reply_to = addr.get("address", "") or from_str
        subj = em.get("subject", "")
        body = em.get("bodyPreview", "") or (em.get("body") or {}).get("content", "") if isinstance(em.get("body"), dict) else ""
        if body and len(str(body)) > 1500:
            body = str(body)[:1500] + "..."
        parts.append(f"From: {from_str}")
        parts.append(f"Subject: {subj}")
        parts.append(f"Content:\n{body}")
        parts.append("")
    if reply_to:
        parts.insert(2, f"Recipient (reply to this address): {reply_to}")
        parts.insert(3, "")
    context = "\n".join(parts).strip()
    return (context, docs, reply_to)


def _buildSearchQuery(
    query: str = None,
    fromAddress: str = None,
    toAddress: str = None,
    subjectContains: str = None,
    bodyContains: str = None,
    hasAttachment: bool = None,
    filter: str = None,
) -> str:
    """
    Build Microsoft Graph $search query from discrete params.
    Uses KQL: from:, to:, subject:, body:, hasattachments: (supported by Graph API).
    """
    if filter and str(filter).strip():
        return str(filter).strip()
    parts = []
    if query and str(query).strip():
        parts.append(str(query).strip())
    if fromAddress and str(fromAddress).strip():
        safe = str(fromAddress).strip().replace('"', '')
        parts.append(f'from:{safe}')
    if toAddress and str(toAddress).strip():
        safe = str(toAddress).strip().replace('"', '')
        parts.append(f'to:{safe}')
    if subjectContains and str(subjectContains).strip():
        safe = str(subjectContains).strip().replace('"', '')
        parts.append(f'subject:{safe}')
    if bodyContains and str(bodyContains).strip():
        safe = str(bodyContains).strip().replace('"', '')
        parts.append(f'body:{safe}')
    if hasAttachment is True:
        parts.append("hasattachments:true")
    return " ".join(parts) if parts else "*"


def _buildEmailFilter(fromAddress: str = None, subjectContains: str = None, hasAttachment: bool = None) -> str:
    """
    Build Microsoft Graph API $filter string from discrete email filter params.
    Used for email.checkEmail (and trigger.newEmail).
    """
    parts = []
    if fromAddress and str(fromAddress).strip():
        safe = str(fromAddress).strip().replace("'", "''")
        parts.append(f"from/emailAddress/address eq '{safe}'")
    if subjectContains and str(subjectContains).strip():
        safe = str(subjectContains).strip().replace("'", "''")
        parts.append(f"contains(subject,'{safe}')")
    if hasAttachment is True:
        parts.append("hasAttachments eq true")
    return " and ".join(parts) if parts else ""


def _buildActionParams(
    node: Dict[str, Any],
    nodeDef: Dict[str, Any],
    resolvedParams: Dict[str, Any],
    chatService,
    services=None,
) -> Dict[str, Any]:
    """
    Build params for ActionExecutor from node parameters using _paramMap.
    Resolves connectionId -> connectionReference.
    Handles _contextFrom for composite params (e.g. email.draftEmail subject+body -> context).
    """
    params = dict(resolvedParams)
    paramMap = nodeDef.get("_paramMap") or {}
    contextFrom = nodeDef.get("_contextFrom") or []

    # email.checkEmail: build filter from discrete params (fromAddress, subjectContains, hasAttachment)
    nodeType = node.get("type", "")
    if nodeType == "email.checkEmail":
        built = _buildEmailFilter(
            fromAddress=params.get("fromAddress"),
            subjectContains=params.get("subjectContains"),
            hasAttachment=params.get("hasAttachment"),
        )
        raw_filter = (params.get("filter") or "").strip()
        params["filter"] = built if built else (raw_filter if raw_filter else None)
        params.pop("fromAddress", None)
        params.pop("subjectContains", None)
        params.pop("hasAttachment", None)

    # email.searchEmail: build query from discrete params (fromAddress, toAddress, subjectContains, bodyContains, hasAttachment)
    if nodeType == "email.searchEmail":
        built = _buildSearchQuery(
            query=params.get("query"),
            fromAddress=params.get("fromAddress"),
            toAddress=params.get("toAddress"),
            subjectContains=params.get("subjectContains"),
            bodyContains=params.get("bodyContains"),
            hasAttachment=params.get("hasAttachment"),
            filter=params.get("filter"),
        )
        params["query"] = built
        params.pop("fromAddress", None)
        params.pop("toAddress", None)
        params.pop("subjectContains", None)
        params.pop("bodyContains", None)
        params.pop("hasAttachment", None)
        params.pop("filter", None)

    # Resolve connectionId to connectionReference
    if "connectionId" in params:
        connId = params.get("connectionId")
        if connId:
            ref = _resolveConnectionIdToReference(chatService, connId, services)
            if ref:
                params["connectionReference"] = ref
            else:
                logger.warning(f"Could not resolve connectionId {connId} to connectionReference")
        params.pop("connectionId", None)

    # Build context from multiple params (e.g. subject + body for draft email)
    if contextFrom:
        parts = []
        for key in contextFrom:
            val = params.get(key)
            if val:
                if key == "subject":
                    parts.append(f"Subject: {val}")
                elif key == "body":
                    parts.append(f"Body:\n{val}")
                else:
                    parts.append(str(val))
        if parts:
            params["context"] = "\n\n".join(parts)
        for k in contextFrom:
            params.pop(k, None)

    # Apply paramMap: node param name -> action param name
    result = {}
    mappedNodeKeys = {nodeKey for nodeKey, actionKey in paramMap.items() if actionKey and nodeKey in params}
    for nodeKey, actionKey in paramMap.items():
        if nodeKey in params and actionKey:
            result[actionKey] = params[nodeKey]
    # Pass through params not used as source for mapping
    for k, v in params.items():
        if k not in mappedNodeKeys and k not in result:
            result[k] = v
    return result


class ActionNodeExecutor:
    """Execute ai.*, email.*, sharepoint.* nodes by mapping to method actions."""

    def __init__(self, services: Any):
        self.services = services

    async def execute(
        self,
        node: Dict[str, Any],
        context: Dict[str, Any],
    ) -> Any:
        from modules.features.automation2.nodeRegistry import getNodeTypeToMethodAction
        from modules.workflows.automation2.graphUtils import resolveParameterReferences
        from modules.workflows.processing.core.actionExecutor import ActionExecutor

        nodeType = node.get("type", "")
        nodeId = node.get("id", "")
        logger.info("ActionNodeExecutor node %s type=%s", nodeId, nodeType)

        mapping = getNodeTypeToMethodAction()
        methodAction = mapping.get(nodeType)
        if not methodAction:
            logger.debug("ActionNodeExecutor node %s not in mapping -> None", nodeId)
            return None

        methodName, actionName = methodAction
        logger.info("ActionNodeExecutor node %s method=%s action=%s", nodeId, methodName, actionName)

        nodeDef = _getNodeDefinition(nodeType)
        params = dict(node.get("parameters") or {})
        resolvedParams = resolveParameterReferences(params, context.get("nodeOutputs", {}))

        # Merge input from connected nodes (documentList, etc.)
        inputSources = context.get("inputSources", {}).get(nodeId, {})
        if 0 in inputSources:
            srcId, _ = inputSources[0]
            inp = context.get("nodeOutputs", {}).get(srcId)
            if isinstance(inp, dict):
                resolvedParams.setdefault("documentList", inp.get("documents", inp.get("documentList", [])))
            elif inp is not None:
                resolvedParams.setdefault("input", inp)

        # ai.prompt with email upstream: inject actual email content into prompt so AI has context
        # (getChatDocumentsFromDocumentList fails in automation2 – workflow has no messages)
        if nodeType.startswith("ai."):
            orderedNodes = context.get("_orderedNodes") or []
            if 0 in inputSources:
                srcId, _ = inputSources[0]
                srcNode = next((n for n in orderedNodes if n.get("id") == srcId), None)
                srcType = (srcNode or {}).get("type", "")
                if srcType in ("email.checkEmail", "email.searchEmail"):
                    incoming = _getIncomingEmailFromUpstream(
                        nodeId,
                        context.get("inputSources", {}),
                        context.get("nodeOutputs", {}),
                        orderedNodes,
                    )
                    if incoming:
                        ctx, _doc_list, _reply_to = incoming
                        if ctx and ctx.strip():
                            base_prompt = (resolvedParams.get("aiPrompt") or "").strip()
                            resolvedParams["aiPrompt"] = (
                                f"Eingehende E-Mail:\n{ctx}\n\nAufgabe: {base_prompt}"
                                if base_prompt
                                else f"Eingehende E-Mail:\n{ctx}"
                            )
                            logger.debug("ai.prompt: injected email context from upstream %s", srcType)

        chatService = getattr(self.services, "chat", None)
        actionParams = _buildActionParams(node, nodeDef or {}, resolvedParams, chatService, self.services)

        # email.checkEmail: pause and wait for new email (background poller will resume)
        if nodeType == "email.checkEmail":
            runId = context.get("_runId")
            workflowId = context.get("workflowId")
            connRef = actionParams.get("connectionReference")
            if runId and workflowId and connRef:
                from modules.workflows.automation2.executors import PauseForEmailWaitError
                waitConfig = {
                    "connectionReference": connRef,
                    "folder": actionParams.get("folder", "Inbox"),
                    "limit": min(int(actionParams.get("limit") or 10), 50),
                    "filter": actionParams.get("filter"),
                }
                raise PauseForEmailWaitError(runId=runId, nodeId=nodeId, waitConfig=waitConfig)
            # Fallback: no pause (calls readEmails directly) – needs runId, workflowId, connectionReference
            if not runId or not workflowId:
                logger.warning(
                    "email.checkEmail not pausing (runId=%s workflowId=%s) – run must be saved/executed as workflow",
                    runId,
                    workflowId,
                )
            elif not connRef:
                logger.warning(
                    "email.checkEmail not pausing – connectionReference missing (check connectionId/config)",
                )

        # email.draftEmail: use AI output as emailContent if available; else pass incoming email as context
        if nodeType == "email.draftEmail":
            inputSources = context.get("inputSources", {})
            nodeOutputs = context.get("nodeOutputs", {})
            orderedNodes = context.get("_orderedNodes") or []
            if 0 in inputSources.get(nodeId, {}):
                srcId, _ = inputSources[nodeId][0]
                srcNode = next((n for n in orderedNodes if n.get("id") == srcId), None)
                srcType = (srcNode or {}).get("type", "")
                if srcType.startswith("ai."):
                    inp = nodeOutputs.get(srcId)
                    email_content = _extractEmailContentFromUpstream(inp)
                    if email_content:
                        actionParams["emailContent"] = email_content
                        actionParams["context"] = email_content.get("body", "") or "(from connected AI node)"
                    # Attachments: gather from file nodes upstream of AI (e.g. downloadFile -> AI -> email)
                    attachment_docs = _gatherAttachmentDocumentsFromUpstream(
                        nodeId, inputSources, nodeOutputs, orderedNodes
                    )
                    if attachment_docs:
                        existing = actionParams.get("documentList") or []
                        # Prefer file docs from upstream; append any existing that look like binary attachments
                        def _is_binary_attachment(d):
                            if isinstance(d, dict) and d.get("documentData"):
                                try:
                                    import json
                                    json.loads(d["documentData"])
                                    return False  # JSON = email content, not attachment
                                except (TypeError, ValueError):
                                    return True
                            return bool(isinstance(d, dict) and (d.get("validationMetadata") or {}).get("fileId"))
                        extra = [x for x in (existing if isinstance(existing, list) else []) if _is_binary_attachment(x)]
                        actionParams["documentList"] = attachment_docs + extra
                    if not email_content:
                        # AI returns plain text (e.g. email.txt): use as email body directly (no extra AI call)
                        ctx = _extractContextFromUpstream(inp)
                        if ctx:
                            actionParams["emailContent"] = {
                                "subject": actionParams.get("subject", "Draft"),
                                "body": ctx,
                                "to": actionParams.get("to"),
                            }
                            actionParams["context"] = ctx
                        else:
                            # Fallback: incoming email from upstream (if flow is email->AI->draft)
                            incoming = _getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes)
                            if incoming:
                                ctx, doc_list, reply_to = incoming
                                actionParams["context"] = ctx
                                if doc_list and not actionParams.get("documentList"):
                                    actionParams["documentList"] = doc_list
                                if reply_to and not actionParams.get("to"):
                                    actionParams["to"] = [reply_to]
                            else:
                                doc_count = len(inp.get("documents", [])) if isinstance(inp, dict) else 0
                                logger.warning(
                                    "email.draftEmail: AI upstream returned %d doc(s) but context extraction failed (no subject/body, no plain text). "
                                    "Ensure AI node outputs document with documentData.",
                                    doc_count,
                                )
                                actionParams["context"] = "(no context extracted from upstream – check AI node output)"
                elif srcType in ("sharepoint.downloadFile", "sharepoint.readFile"):
                    # File itself is the context: pass as attachment, use filename as minimal context (no content extraction)
                    if not actionParams.get("context"):
                        inp = nodeOutputs.get(srcId)
                        docs = (inp.get("documents") or inp.get("documentList", [])) if isinstance(inp, dict) else []
                        doc = docs[0] if docs else None
                        name = None
                        if isinstance(doc, dict):
                            name = doc.get("documentName") or doc.get("fileName")
                        elif doc and hasattr(doc, "documentName"):
                            name = getattr(doc, "documentName", None) or getattr(doc, "fileName", None)
                        ctx = name if name else "Attachment"
                        actionParams["context"] = ctx
                        actionParams["emailContent"] = {
                            "subject": actionParams.get("subject", "Draft"),
                            "body": ctx,
                            "to": actionParams.get("to"),
                        }
                    # documentList already merged from upstream (file as attachment)
                else:
                    # Direct connection to email.checkEmail/searchEmail: use incoming email as context
                    if not actionParams.get("context"):
                        incoming = _getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes)
                        if incoming:
                            ctx, doc_list, reply_to = incoming
                            actionParams["context"] = ctx
                            if doc_list and not actionParams.get("documentList"):
                                actionParams["documentList"] = doc_list
                            if reply_to and not actionParams.get("to"):
                                actionParams["to"] = [reply_to]

        # Generic context handover: when upstream provides documents, pass first doc as content for actions that expect it
        docList = actionParams.get("documentList") or resolvedParams.get("documentList")
        if docList and "content" not in actionParams:
            first = docList[0] if isinstance(docList, list) and docList else docList
            # Actions like sharepoint.uploadFile consume content from context
            actionParams["content"] = first

        executor = ActionExecutor(self.services)
        logger.info("ActionNodeExecutor node %s calling executeAction(%s, %s)", nodeId, methodName, actionName)
        result = await executor.executeAction(methodName, actionName, actionParams)

        out = {
            "success": result.success,
            "error": result.error,
            "documents": [d.model_dump() if hasattr(d, "model_dump") else d for d in (result.documents or [])],
            "data": result.model_dump() if hasattr(result, "model_dump") else {"success": result.success, "error": result.error},
        }
        logger.info(
            "ActionNodeExecutor node %s result: success=%s error=%s doc_count=%d",
            nodeId,
            result.success,
            result.error,
            len(out.get("documents", [])),
        )
        return out
