"""ToolNode — tool invocation node.

Calls any registered tool by name, resolving template arguments
from upstream inputs.
"""

from __future__ import annotations

import logging
import re
from typing import TYPE_CHECKING, Literal

from pydantic import Field

from core.common import JsonObject
from core.pathways.models.io import NodeInputs, NodeOutputs
from core.pathways.models.tool import ToolArgs, ToolOutput
from core.pathways.nodes.base import NodeBase

if TYPE_CHECKING:
    from core.pathways.context import Context

logger = logging.getLogger(__name__)

_UNRESOLVED_RE = re.compile(r"\{\{[^}]+\}\}")

# A template is "pure" when it's a single {{expr}} with nothing else.
# Pure templates resolving to "" almost always mean the variable was missing.
# Templates with surrounding text, Jinja2 logic, or default filters can
# legitimately resolve to "" (e.g., optional unit number).
_PURE_PLACEHOLDER_RE = re.compile(r"^\s*\{\{[^}]+\}\}\s*$")


class ToolNode(NodeBase):
    """Tool invocation — the primary node type.

    Calls any tool from ctx.tools by name. Resolves ``{{var}}`` templates
    in arguments from upstream node outputs.

    Attributes
    ----------
    tool : str
        Fully qualified tool name (e.g., ``"web.search"``, ``"workspace.read_file"``).
    args : JsonObject
        Static arguments with optional ``{{var}}`` template placeholders.
    """

    type: Literal["tool"] = "tool"
    tool: str
    args: JsonObject = Field(default_factory=dict)

    async def compute(self, inputs: NodeInputs, ctx: Context) -> NodeOutputs:
        """Execute tool call, resolving templates and merging inputs."""
        template_data = inputs.to_dict()
        handler = ctx.tools.get(self.tool)
        if not handler:
            msg = f"Tool not found: {self.tool}"
            raise RuntimeError(msg)

        resolved = self._resolve_templates(dict(self.args), template_data)
        call_args: dict[str, object] = resolved if isinstance(resolved, dict) else {}

        # Detect templates that failed to resolve (silently became empty strings).
        # This catches the common case where upstream LLM output isn't structured.
        self._check_unresolved_templates(self.args, call_args)

        for key, value in inputs.items():
            if key not in call_args:
                call_args[key] = value

        raw = await handler(ToolArgs(**call_args), ctx)
        tool_out = raw if isinstance(raw, ToolOutput) else ToolOutput.model_validate(raw)
        output_dict = tool_out.model_dump()

        # Promote data fields so downstream templates can use
        # {{create_job.job_uuid}} instead of {{create_job.output.data.job_uuid}}.
        if tool_out.success and isinstance(tool_out.data, dict):
            output_dict.update(tool_out.data)

        return NodeOutputs(output=output_dict)

    @staticmethod
    def _check_unresolved_templates(
        original: JsonObject,
        resolved: dict[str, object],
    ) -> None:
        """Raise if a *pure* placeholder resolved to an empty string.

        When a ``{{node.field}}`` placeholder refers to a missing upstream
        field, Jinja2's SilentUndefined renders it as ``""``.  This silently
        passes an empty string as a tool argument, which almost always fails
        downstream.  Detecting it here gives an actionable error message.

        Only pure single-placeholder templates (``"{{x.y}}"``) are flagged.
        Templates with Jinja2 logic, default filters, or surrounding literal
        text can legitimately resolve to ``""`` (e.g., an optional unit
        number that the tenant didn't provide).
        """
        for key, orig_val in original.items():
            if not isinstance(orig_val, str) or "{{" not in orig_val:
                continue
            res_val = resolved.get(key)

            # Template markers survived resolution (shouldn't happen with
            # SilentUndefined, but guard against it)
            if isinstance(res_val, str) and _UNRESOLVED_RE.search(res_val):
                msg = (
                    f"Unresolved template in tool arg '{key}': {res_val}. "
                    f"The upstream node may not have produced the expected field."
                )
                raise ValueError(msg)

            # Only flag empty strings for pure single-placeholder templates.
            # If the template has logic ({% if %}), defaults, or surrounding
            # text, an empty result is intentional.
            if isinstance(res_val, str) and res_val == "" and _PURE_PLACEHOLDER_RE.match(orig_val):
                msg = (
                    f"Unresolved template in tool arg '{key}': {orig_val} "
                    f"resolved to empty string. The upstream node may not have "
                    f"produced structured output. Ensure the LLM node uses "
                    f"response_format='json' so parsed fields are available."
                )
                raise ValueError(msg)


__all__ = ["ToolNode"]
