"""AgentValidator — validates agent JSON graphs for correctness."""

import json
import logging
import re
from typing import Any

from backend.data.dynamic_fields import DICT_SPLIT

from .helpers import (
    AGENT_EXECUTOR_BLOCK_ID,
    AGENT_INPUT_BLOCK_ID,
    AGENT_OUTPUT_BLOCK_ID,
    MCP_TOOL_BLOCK_ID,
    TOOL_ORCHESTRATOR_BLOCK_ID,
    AgentDict,
    are_types_compatible,
    get_defined_property_type,
)

logger = logging.getLogger(__name__)


class AgentValidator:
    """
    A comprehensive validator for AutoGPT agents that provides detailed error
    reporting for LLM-based fixes.
    """

    def __init__(self):
        self.errors: list[str] = []

    def add_error(self, error_message: str) -> None:
        """Add an error message to the validation errors list."""
        self.errors.append(error_message)

    def _values_equal(self, val1: Any, val2: Any) -> bool:
        """Compare two values, handling complex types like dicts and lists."""
        if type(val1) is not type(val2):
            return False
        if isinstance(val1, dict):
            return json.dumps(val1, sort_keys=True) == json.dumps(val2, sort_keys=True)
        if isinstance(val1, list):
            return json.dumps(val1, sort_keys=True) == json.dumps(val2, sort_keys=True)
        return val1 == val2

    def validate_block_existence(
        self, agent: AgentDict, blocks: list[dict[str, Any]]
    ) -> bool:
        """
        Validate that all block IDs used in the agent actually exist in the
        blocks list. Returns True if all block IDs exist, False otherwise.
        """
        valid = True

        # Create a set of all valid block IDs for fast lookup
        valid_block_ids = {block.get("id") for block in blocks if block.get("id")}

        # Check each node's block_id
        for node in agent.get("nodes", []):
            block_id = node.get("block_id")
            node_id = node.get("id")

            if not block_id:
                self.add_error(
                    f"Node '{node_id}' is missing a 'block_id' field. "
                    f"Every node must reference a valid block."
                )
                valid = False
                continue

            if block_id not in valid_block_ids:
                self.add_error(
                    f"Node '{node_id}' references block_id '{block_id}' "
                    f"which does not exist in the available blocks. "
                    f"This block may have been deprecated, removed, or "
                    f"the ID is incorrect. Please use a valid block from "
                    f"the blocks library."
                )
                valid = False

        return valid

    def validate_link_node_references(self, agent: AgentDict) -> bool:
        """
        Validate that all node IDs referenced in links actually exist in the
        agent's nodes. Returns True if all link references are valid, False
        otherwise.
        """
        valid = True

        # Create a set of all valid node IDs for fast lookup
        valid_node_ids = {
            node.get("id") for node in agent.get("nodes", []) if node.get("id")
        }

        # Check each link's source_id and sink_id
        for link in agent.get("links", []):
            link_id = link.get("id", "Unknown")
            source_id = link.get("source_id")
            sink_id = link.get("sink_id")
            source_name = link.get("source_name", "")
            sink_name = link.get("sink_name", "")

            # Check source_id
            if not source_id:
                self.add_error(
                    f"Link '{link_id}' is missing a 'source_id' field. "
                    f"Every link must reference a valid source node."
                )
                valid = False
            elif source_id not in valid_node_ids:
                self.add_error(
                    f"Link '{link_id}' references source_id '{source_id}' "
                    f"which does not exist in the agent's nodes. The link "
                    f"from '{source_name}' cannot be established because "
                    f"the source node is missing."
                )
                valid = False

            # Check sink_id
            if not sink_id:
                self.add_error(
                    f"Link '{link_id}' is missing a 'sink_id' field. "
                    f"Every link must reference a valid sink (destination) "
                    f"node."
                )
                valid = False
            elif sink_id not in valid_node_ids:
                self.add_error(
                    f"Link '{link_id}' references sink_id '{sink_id}' "
                    f"which does not exist in the agent's nodes. The link "
                    f"to '{sink_name}' cannot be established because the "
                    f"destination node is missing."
                )
                valid = False

        return valid

    def validate_required_inputs(
        self, agent: AgentDict, blocks: list[dict[str, Any]]
    ) -> bool:
        """
        Validate that all required inputs are provided for each node.
        Returns True if all required inputs are satisfied, False otherwise.
        """
        valid = True

        block_lookup = {b.get("id", ""): b for b in blocks}

        for node in agent.get("nodes", []):
            block_id = node.get("block_id")
            block = block_lookup.get(block_id)

            if not block:
                continue

            required_inputs = block.get("inputSchema", {}).get("required", [])
            input_defaults = node.get("input_default", {})
            node_id = node.get("id")

            linked_inputs = set(
                link.get("sink_name")
                for link in agent.get("links", [])
                if link.get("sink_id") == node_id and link.get("sink_name")
            )

            for req_input in required_inputs:
                if (
                    req_input not in input_defaults
                    and req_input not in linked_inputs
                    and req_input != "credentials"
                ):
                    block_name = block.get("name", "Unknown Block")
                    self.add_error(
                        f"Node '{node_id}' (block '{block_name}' - "
                        f"{block_id}) is missing required input "
                        f"'{req_input}'. This input must be either "
                        f"provided as a default value in the node's "
                        f"'input_default' field or connected via a link "
                        f"from another node's output."
                    )
                    valid = False

        return valid

    def _build_node_lookup(self, agent: AgentDict) -> dict[str, dict[str, Any]]:
        """Build a node-id → node dict from the agent's nodes."""
        return {node.get("id", ""): node for node in agent.get("nodes", [])}

    def validate_data_type_compatibility(
        self,
        agent: AgentDict,
        blocks: list[dict[str, Any]],
        node_lookup: dict[str, dict[str, Any]] | None = None,
    ) -> bool:
        """
        Validate that linked data types are compatible between source and sink.
        Returns True if all data types are compatible, False otherwise.
        """
        valid = True
        if node_lookup is None:
            node_lookup = self._build_node_lookup(agent)
        block_lookup = {block.get("id", ""): block for block in blocks}

        for link in agent.get("links", []):
            source_id = link.get("source_id")
            sink_id = link.get("sink_id")
            source_name = link.get("source_name")
            sink_name = link.get("sink_name")

            if not all(
                isinstance(v, str) and v
                for v in (source_id, sink_id, source_name, sink_name)
            ):
                self.add_error(
                    f"Link '{link.get('id', 'Unknown')}' is missing required "
                    f"fields (source_id/sink_id/source_name/sink_name)."
                )
                valid = False
                continue

            source_node = node_lookup.get(source_id)
            sink_node = node_lookup.get(sink_id)

            if not source_node or not sink_node:
                continue

            source_block = block_lookup.get(source_node.get("block_id", ""))
            sink_block = block_lookup.get(sink_node.get("block_id", ""))

            if not source_block or not sink_block:
                continue

            source_outputs = source_block.get("outputSchema", {}).get("properties", {})
            sink_inputs = sink_block.get("inputSchema", {}).get("properties", {})

            source_type = get_defined_property_type(source_outputs, source_name)
            sink_type = get_defined_property_type(sink_inputs, sink_name)

            if (
                source_type
                and sink_type
                and not are_types_compatible(source_type, sink_type)
            ):
                source_block_name = source_block.get("name", "Unknown Block")
                sink_block_name = sink_block.get("name", "Unknown Block")
                self.add_error(
                    f"Data type mismatch in link '{link.get('id')}': "
                    f"Source '{source_block_name}' output "
                    f"'{link.get('source_name', '')}' outputs '{source_type}' "
                    f"type, but sink '{sink_block_name}' input "
                    f"'{link.get('sink_name', '')}' expects '{sink_type}' type. "
                    f"These types must match for the connection to work "
                    f"properly."
                )
                valid = False

        return valid

    def validate_prompt_double_curly_braces_spaces(self, agent: AgentDict) -> bool:
        """
        Validate that prompt parameters do not contain spaces in double curly
        braces.

        Checks the 'prompt' parameter in input_default of each node and reports
        errors if values within double curly braces ({{...}}) contain spaces.
        For example, {{user name}} should be {{user_name}}.

        Args:
            agent: The agent dictionary to validate

        Returns:
            True if all prompts are valid (no spaces in double curly braces),
            False otherwise
        """
        valid = True
        nodes = agent.get("nodes", [])

        for node in nodes:
            node_id = node.get("id")
            input_default = node.get("input_default", {})

            # Check if 'prompt' parameter exists
            if "prompt" not in input_default:
                continue

            prompt_text = input_default["prompt"]

            # Only process if it's a string
            if not isinstance(prompt_text, str):
                continue

            # Find all double curly brace patterns with spaces
            matches = re.finditer(r"\{\{([^}]+)\}\}", prompt_text)

            for match in matches:
                content = match.group(1)
                if " " in content:
                    start_pos = match.start()
                    snippet_start = max(0, start_pos - 30)
                    snippet_end = min(len(prompt_text), match.end() + 30)
                    snippet = prompt_text[snippet_start:snippet_end]

                    self.add_error(
                        f"Node '{node_id}' has spaces in double curly "
                        f"braces in prompt parameter: "
                        f"'{{{{{content}}}}}' should be "
                        f"'{{{{{content.replace(' ', '_')}}}}}'. "
                        f"Context: ...{snippet}..."
                    )
                    valid = False

        return valid

    def validate_source_output_existence(
        self,
        agent: AgentDict,
        blocks: list[dict[str, Any]],
        node_lookup: dict[str, dict[str, Any]] | None = None,
    ) -> bool:
        """
        Validate that all source_names in links exist in the corresponding
        block's output schema.

        Checks that for each link, the source_name field references a valid
        output property in the source block's outputSchema. Also handles nested
        outputs with _#_ notation.

        Args:
            agent: The agent dictionary to validate
            blocks: List of available blocks with their schemas
            node_lookup: Optional pre-built node-id → node dict

        Returns:
            True if all source output fields exist, False otherwise
        """
        valid = True

        # Create lookup dictionaries for efficiency
        block_output_schemas = {
            block.get("id", ""): block.get("outputSchema", {}).get("properties", {})
            for block in blocks
        }
        block_names = {
            block.get("id", ""): block.get("name", "Unknown Block") for block in blocks
        }
        if node_lookup is None:
            node_lookup = self._build_node_lookup(agent)

        for link in agent.get("links", []):
            source_id = link.get("source_id")
            source_name = link.get("source_name", "")
            link_id = link.get("id", "Unknown")

            if not source_name:
                self.add_error(
                    f"Link '{link_id}' is missing 'source_name'. "
                    f"Every link must specify which output field to read from."
                )
                valid = False
                continue

            source_node = node_lookup.get(source_id)
            if not source_node:
                # This error is already caught by
                # validate_link_node_references
                continue

            block_id = source_node.get("block_id")
            block_name = block_names.get(block_id, "Unknown Block")

            # Special handling for AgentExecutorBlock - use dynamic
            # output_schema from input_default
            if block_id == AGENT_EXECUTOR_BLOCK_ID:
                input_default = source_node.get("input_default", {})
                dynamic_output_schema = input_default.get("output_schema", {})
                if not isinstance(dynamic_output_schema, dict):
                    dynamic_output_schema = {}
                output_props = dynamic_output_schema.get("properties", {})
                if not isinstance(output_props, dict):
                    output_props = {}
            else:
                output_props = block_output_schemas.get(block_id, {})

            # Handle nested source names (with _#_ notation)
            if DICT_SPLIT in source_name:
                parent, child = source_name.split(DICT_SPLIT, 1)

                parent_schema = output_props.get(parent)
                if not parent_schema:
                    self.add_error(
                        f"Invalid source output field '{source_name}' "
                        f"in link '{link_id}' from node '{source_id}' "
                        f"(block '{block_name}' - {block_id}): Parent "
                        f"property '{parent}' does not exist in the "
                        f"block's output schema."
                    )
                    valid = False
                    continue

                # Check if additionalProperties is allowed either directly
                # or via anyOf
                allows_additional_properties = parent_schema.get(
                    "additionalProperties", False
                )
                if not allows_additional_properties and "anyOf" in parent_schema:
                    any_of_schemas = parent_schema.get("anyOf", [])
                    if isinstance(any_of_schemas, list):
                        for schema_option in any_of_schemas:
                            if isinstance(schema_option, dict) and schema_option.get(
                                "additionalProperties"
                            ):
                                allows_additional_properties = True
                                break
                            # Also allow when items have
                            # additionalProperties (array of objects)
                            if (
                                isinstance(schema_option, dict)
                                and "items" in schema_option
                            ):
                                items_schema = schema_option.get("items")
                                if isinstance(items_schema, dict) and items_schema.get(
                                    "additionalProperties"
                                ):
                                    allows_additional_properties = True
                                    break

                # Only require child in properties when
                # additionalProperties is not allowed
                if not allows_additional_properties:
                    if not (
                        isinstance(parent_schema, dict)
                        and "properties" in parent_schema
                        and isinstance(parent_schema["properties"], dict)
                        and child in parent_schema["properties"]
                    ):
                        available_props = (
                            list(parent_schema.get("properties", {}).keys())
                            if isinstance(parent_schema, dict)
                            else []
                        )
                        self.add_error(
                            f"Invalid nested source output field "
                            f"'{source_name}' in link '{link_id}' from "
                            f"node '{source_id}' (block "
                            f"'{block_name}' - {block_id}): Child "
                            f"property '{child}' does not exist in "
                            f"parent '{parent}' output schema. "
                            f"Available properties: {available_props}"
                        )
                        valid = False
            else:
                # Check simple (non-nested) source name
                if source_name not in output_props:
                    available_outputs = list(output_props.keys())
                    self.add_error(
                        f"Invalid source output field '{source_name}' "
                        f"in link '{link_id}' from node '{source_id}' "
                        f"(block '{block_name}' - {block_id}): Output "
                        f"property '{source_name}' does not exist in "
                        f"the block's output schema. Available outputs: "
                        f"{available_outputs}"
                    )
                    valid = False

        return valid

    def validate_sink_input_existence(
        self,
        agent: AgentDict,
        blocks: list[dict[str, Any]],
        node_lookup: dict[str, dict[str, Any]] | None = None,
    ) -> bool:
        """
        Validate that all sink_names in links and input_default keys in nodes
        exist in the corresponding block's input schema.

        Checks that for each link the sink_name references a valid input
        property in the sink block's inputSchema, and that every key in a
        node's input_default is a recognised input property. Also handles
        nested inputs with _#_ notation and dynamic schemas for
        AgentExecutorBlock.

        Args:
            agent: The agent dictionary to validate
            blocks: List of available blocks with their schemas
            node_lookup: Optional pre-built node-id → node dict

        Returns:
            True if all sink input fields exist, False otherwise
        """
        valid = True

        block_input_schemas = {
            block.get("id", ""): block.get("inputSchema", {}).get("properties", {})
            for block in blocks
        }
        block_names = {
            block.get("id", ""): block.get("name", "Unknown Block") for block in blocks
        }
        if node_lookup is None:
            node_lookup = self._build_node_lookup(agent)

        def get_input_props(node: dict[str, Any]) -> dict[str, Any]:
            block_id = node.get("block_id", "")
            if block_id == AGENT_EXECUTOR_BLOCK_ID:
                input_default = node.get("input_default", {})
                dynamic_input_schema = input_default.get("input_schema", {})
                if not isinstance(dynamic_input_schema, dict):
                    dynamic_input_schema = {}
                dynamic_props = dynamic_input_schema.get("properties", {})
                if not isinstance(dynamic_props, dict):
                    dynamic_props = {}
                static_props = block_input_schemas.get(block_id, {})
                return {**static_props, **dynamic_props}
            return block_input_schemas.get(block_id, {})

        def check_nested_input(
            input_props: dict[str, Any],
            field_name: str,
            context: str,
            block_name: str,
            block_id: str,
        ) -> bool:
            parent, child = field_name.split(DICT_SPLIT, 1)
            parent_schema = input_props.get(parent)
            if not parent_schema:
                self.add_error(
                    f"{context}: Parent property '{parent}' does not "
                    f"exist in block '{block_name}' ({block_id}) input "
                    f"schema."
                )
                return False

            allows_additional = parent_schema.get("additionalProperties", False)
            # Only anyOf is checked here because Pydantic's JSON schema
            # emits optional/union fields via anyOf. allOf and oneOf are
            # not currently used by any block's dict-typed inputs, so
            # false positives from them are not a concern in practice.
            if not allows_additional and "anyOf" in parent_schema:
                for schema_option in parent_schema.get("anyOf", []):
                    if not isinstance(schema_option, dict):
                        continue
                    if schema_option.get("additionalProperties"):
                        allows_additional = True
                        break
                    items_schema = schema_option.get("items")
                    if isinstance(items_schema, dict) and items_schema.get(
                        "additionalProperties"
                    ):
                        allows_additional = True
                        break

            if not allows_additional:
                if not (
                    isinstance(parent_schema, dict)
                    and "properties" in parent_schema
                    and isinstance(parent_schema["properties"], dict)
                    and child in parent_schema["properties"]
                ):
                    available = (
                        list(parent_schema.get("properties", {}).keys())
                        if isinstance(parent_schema, dict)
                        else []
                    )
                    self.add_error(
                        f"{context}: Child property '{child}' does not "
                        f"exist in parent '{parent}' of block "
                        f"'{block_name}' ({block_id}) input schema. "
                        f"Available properties: {available}"
                    )
                    return False
            return True

        for link in agent.get("links", []):
            sink_id = link.get("sink_id")
            sink_name = link.get("sink_name", "")
            link_id = link.get("id", "Unknown")

            if not sink_name:
                # Missing sink_name is caught by validate_data_type_compatibility
                continue

            sink_node = node_lookup.get(sink_id)
            if not sink_node:
                # Already caught by validate_link_node_references
                continue

            block_id = sink_node.get("block_id", "")
            block_name = block_names.get(block_id, "Unknown Block")
            input_props = get_input_props(sink_node)

            context = (
                f"Invalid sink input field '{sink_name}' in link "
                f"'{link_id}' to node '{sink_id}'"
            )

            if DICT_SPLIT in sink_name:
                if not check_nested_input(
                    input_props, sink_name, context, block_name, block_id
                ):
                    valid = False
            else:
                if sink_name not in input_props:
                    available_inputs = list(input_props.keys())
                    self.add_error(
                        f"{context} (block '{block_name}' - {block_id}): "
                        f"Input property '{sink_name}' does not exist in "
                        f"the block's input schema. "
                        f"Available inputs: {available_inputs}"
                    )
                    valid = False

        for node in agent.get("nodes", []):
            node_id = node.get("id")
            block_id = node.get("block_id", "")
            block_name = block_names.get(block_id, "Unknown Block")
            input_default = node.get("input_default", {})

            if not isinstance(input_default, dict) or not input_default:
                continue

            if (
                block_id not in block_input_schemas
                and block_id != AGENT_EXECUTOR_BLOCK_ID
            ):
                continue

            input_props = get_input_props(node)

            for key in input_default:
                if key == "credentials":
                    continue

                context = (
                    f"Node '{node_id}' (block '{block_name}' - {block_id}) "
                    f"has unknown input_default key '{key}'"
                )

                if DICT_SPLIT in key:
                    if not check_nested_input(
                        input_props, key, context, block_name, block_id
                    ):
                        valid = False
                else:
                    if key not in input_props:
                        available_inputs = list(input_props.keys())
                        self.add_error(
                            f"{context} which does not exist in the "
                            f"block's input schema. "
                            f"Available inputs: {available_inputs}"
                        )
                        valid = False

        return valid

    def _collect_io_block_ids(
        self, blocks: list[dict[str, Any]]
    ) -> tuple[set[str], set[str]]:
        """
        Build sets of all input/output block IDs from the blocks registry.

        Input/output blocks are identified by ``uiType`` (populated from
        ``Block.block_type`` at registration time). Specialized subclasses
        like ``AgentGoogleDriveFileInputBlock`` count as input blocks even
        though they have their own block IDs — this matches the runtime
        behavior, where any subclass of ``AgentInputBlock`` exposes a
        user-facing input. The literal base IDs are always included so the
        function works even when called with a minimal blocks list (e.g.
        unit tests).
        """
        input_ids: set[str] = {AGENT_INPUT_BLOCK_ID}
        output_ids: set[str] = {AGENT_OUTPUT_BLOCK_ID}
        for block in blocks:
            block_id = block.get("id")
            if not block_id:
                continue
            ui_type = block.get("uiType")
            if ui_type == "Input":
                input_ids.add(block_id)
            elif ui_type == "Output":
                output_ids.add(block_id)
        return input_ids, output_ids

    def validate_io_blocks(
        self, agent: AgentDict, blocks: list[dict[str, Any]] | None = None
    ) -> bool:
        """
        Validate that the agent has at least one input block and one output
        block. These blocks define the agent's interface.

        Any block whose ``uiType`` is ``"Input"`` satisfies the input
        requirement — including specialized variants like
        ``AgentGoogleDriveFileInputBlock``, ``AgentDropdownInputBlock``,
        ``AgentTableInputBlock``, etc. The equivalent applies for outputs.
        This prevents the validator from forcing agents to keep a throwaway
        base ``AgentInputBlock`` alongside a real specialized input.

        Returns True if both are present, False otherwise.
        """
        valid = True
        input_ids, output_ids = self._collect_io_block_ids(blocks or [])
        node_block_ids = {
            node.get("block_id")
            for node in agent.get("nodes", [])
            if node.get("block_id")
        }

        if not node_block_ids & input_ids:
            self.add_error(
                f"Agent is missing an input block. Every agent must have at "
                f"least one input block to define user-facing inputs. Add a "
                f"node using the base AgentInputBlock (block_id: "
                f"'{AGENT_INPUT_BLOCK_ID}') or any specialized input block "
                f"subclass (e.g. AgentGoogleDriveFileInputBlock, "
                f"AgentDropdownInputBlock, AgentShortTextInputBlock). Set "
                f"input_default with 'name' and optionally 'title'."
            )
            valid = False

        if not node_block_ids & output_ids:
            self.add_error(
                f"Agent is missing an output block. Every agent must have "
                f"at least one output block to define user-facing outputs. "
                f"Add a node using the base AgentOutputBlock (block_id: "
                f"'{AGENT_OUTPUT_BLOCK_ID}') or any specialized output "
                f"block subclass. Set input_default with 'name', then link "
                f"'value' from another block's output."
            )
            valid = False

        return valid

    def validate_agent_executor_blocks(
        self,
        agent: AgentDict,
        library_agents: list[dict[str, Any]] | None = None,
    ) -> bool:
        """
        Validate AgentExecutorBlock nodes have required fields and valid
        references.

        Checks that AgentExecutorBlock nodes:
        1. Have a valid graph_id in input_default (required)
        2. If graph_id matches a known library agent, validates version
           consistency
        3. Sub-agent required inputs are connected via links (not hardcoded)

        Note: Unknown graph_ids are not treated as errors - they could be valid
        direct references to agents by their actual ID (not via library_agents).
        This is consistent with fix_agent_executor_blocks() behavior.

        Args:
            agent: The agent dictionary to validate
            library_agents: List of available library agents (for version
                            validation)

        Returns:
            True if all AgentExecutorBlock nodes are valid, False otherwise
        """
        valid = True
        nodes = agent.get("nodes", [])
        links = agent.get("links", [])

        # Create lookup for library agents
        library_agent_lookup: dict[str, dict[str, Any]] = {}
        if library_agents:
            library_agent_lookup = {la.get("graph_id", ""): la for la in library_agents}

        for node in nodes:
            if node.get("block_id") != AGENT_EXECUTOR_BLOCK_ID:
                continue

            node_id = node.get("id")
            input_default = node.get("input_default", {})

            # Check for required graph_id
            graph_id = input_default.get("graph_id")
            if not graph_id:
                self.add_error(
                    f"AgentExecutorBlock node '{node_id}' is missing "
                    f"required 'graph_id' in input_default. This field "
                    f"must reference the ID of the sub-agent to execute."
                )
                valid = False
                continue

            # If graph_id is not in library_agent_lookup, skip validation
            if graph_id not in library_agent_lookup:
                continue

            # Validate version consistency for known library agents
            library_agent = library_agent_lookup[graph_id]
            expected_version = library_agent.get("graph_version")
            current_version = input_default.get("graph_version")
            if (
                current_version
                and expected_version
                and current_version != expected_version
            ):
                self.add_error(
                    f"AgentExecutorBlock node '{node_id}' has mismatched "
                    f"graph_version: got {current_version}, expected "
                    f"{expected_version} for library agent "
                    f"'{library_agent.get('name')}'"
                )
                valid = False

            # Validate sub-agent inputs are properly linked (not hardcoded)
            sub_agent_input_schema = library_agent.get("input_schema", {})
            if not isinstance(sub_agent_input_schema, dict):
                sub_agent_input_schema = {}
            sub_agent_required_inputs = sub_agent_input_schema.get("required", [])
            sub_agent_properties = sub_agent_input_schema.get("properties", {})

            # Get all linked inputs to this node
            linked_sub_agent_inputs: set[str] = set()
            for link in links:
                if link.get("sink_id") == node_id:
                    sink_name = link.get("sink_name", "")
                    if sink_name in sub_agent_properties:
                        linked_sub_agent_inputs.add(sink_name)

            # Check for hardcoded inputs that should be linked
            hardcoded_inputs = input_default.get("inputs", {})
            input_schema = input_default.get("input_schema", {})
            schema_properties = (
                input_schema.get("properties", {})
                if isinstance(input_schema, dict)
                else {}
            )
            if isinstance(hardcoded_inputs, dict) and hardcoded_inputs:
                for input_name, value in hardcoded_inputs.items():
                    if input_name not in sub_agent_properties:
                        continue
                    if value is None:
                        continue
                    # Skip if this input is already linked
                    if input_name in linked_sub_agent_inputs:
                        continue
                    prop_schema = schema_properties.get(input_name, {})
                    schema_default = (
                        prop_schema.get("default")
                        if isinstance(prop_schema, dict)
                        else None
                    )
                    if schema_default is not None and self._values_equal(
                        value, schema_default
                    ):
                        continue
                    # This is a non-default hardcoded value without a link
                    self.add_error(
                        f"AgentExecutorBlock node '{node_id}' has "
                        f"hardcoded input '{input_name}'. Sub-agent "
                        f"inputs should be connected via links using "
                        f"'{input_name}' as sink_name, not hardcoded "
                        f"in input_default.inputs. Create a link from "
                        f"the appropriate source node."
                    )
                    valid = False

            # Check for missing required sub-agent inputs.
            # An input is satisfied if it is linked OR has an allowed
            # hardcoded value (i.e. equals the schema default — the
            # previous check already flags non-default hardcoded values).
            hardcoded_inputs_dict = (
                hardcoded_inputs if isinstance(hardcoded_inputs, dict) else {}
            )
            for req_input in sub_agent_required_inputs:
                if req_input in linked_sub_agent_inputs:
                    continue
                # Check if fixer populated it with a schema default value
                if req_input in hardcoded_inputs_dict:
                    prop_schema = schema_properties.get(req_input, {})
                    schema_default = (
                        prop_schema.get("default")
                        if isinstance(prop_schema, dict)
                        else None
                    )
                    if schema_default is not None and self._values_equal(
                        hardcoded_inputs_dict[req_input], schema_default
                    ):
                        continue
                self.add_error(
                    f"AgentExecutorBlock node '{node_id}' is "
                    f"missing required sub-agent input "
                    f"'{req_input}'. Create a link to this node "
                    f"using sink_name '{req_input}' to connect "
                    f"the input."
                )
                valid = False

        return valid

    def validate_agent_executor_block_schemas(
        self,
        agent: AgentDict,
    ) -> bool:
        """
        Validate that AgentExecutorBlock nodes have valid input_schema and
        output_schema.

        This validation runs regardless of library_agents availability and
        ensures that the schemas are properly populated to prevent frontend
        crashes.

        Args:
            agent: The agent dictionary to validate

        Returns:
            True if all AgentExecutorBlock nodes have valid schemas, False
            otherwise
        """
        valid = True
        nodes = agent.get("nodes", [])

        for node in nodes:
            if node.get("block_id") != AGENT_EXECUTOR_BLOCK_ID:
                continue

            node_id = node.get("id")
            input_default = node.get("input_default", {})
            customized_name = (node.get("metadata") or {}).get(
                "customized_name", "Unknown"
            )

            # Check input_schema
            input_schema = input_default.get("input_schema")
            if input_schema is None or not isinstance(input_schema, dict):
                self.add_error(
                    f"AgentExecutorBlock node '{node_id}' "
                    f"({customized_name}) has missing or invalid "
                    f"input_schema. The input_schema must be a valid "
                    f"JSON Schema object with 'properties' and "
                    f"'required' fields."
                )
                valid = False
            elif not input_schema.get("properties") and not input_schema.get("type"):
                # Empty schema like {} is invalid
                self.add_error(
                    f"AgentExecutorBlock node '{node_id}' "
                    f"({customized_name}) has empty input_schema. The "
                    f"input_schema must define the sub-agent's expected "
                    f"inputs. This usually indicates the sub-agent "
                    f"reference is incomplete or the library agent was "
                    f"not properly passed."
                )
                valid = False

            # Check output_schema
            output_schema = input_default.get("output_schema")
            if output_schema is None or not isinstance(output_schema, dict):
                self.add_error(
                    f"AgentExecutorBlock node '{node_id}' "
                    f"({customized_name}) has missing or invalid "
                    f"output_schema. The output_schema must be a valid "
                    f"JSON Schema object defining the sub-agent's "
                    f"outputs."
                )
                valid = False
            elif not output_schema.get("properties") and not output_schema.get("type"):
                # Empty schema like {} is invalid
                self.add_error(
                    f"AgentExecutorBlock node '{node_id}' "
                    f"({customized_name}) has empty output_schema. "
                    f"The output_schema must define the sub-agent's "
                    f"expected outputs. This usually indicates the "
                    f"sub-agent reference is incomplete or the library "
                    f"agent was not properly passed."
                )
                valid = False

        return valid

    def validate_orchestrator_blocks(
        self,
        agent: AgentDict,
        node_lookup: dict[str, dict[str, Any]] | None = None,
    ) -> bool:
        """Validate that OrchestratorBlock nodes have downstream tools.

        Checks that each OrchestratorBlock node has at least one link
        with ``source_name == "tools"`` connecting to a downstream block.
        Without tools, the block has nothing to call and will error at runtime.

        Returns True if all OrchestratorBlock nodes are valid.
        """
        valid = True
        nodes = agent.get("nodes", [])
        links = agent.get("links", [])
        if node_lookup is None:
            node_lookup = self._build_node_lookup(agent)
        non_tool_block_ids = {AGENT_INPUT_BLOCK_ID, AGENT_OUTPUT_BLOCK_ID}

        for node in nodes:
            if node.get("block_id") != TOOL_ORCHESTRATOR_BLOCK_ID:
                continue

            node_id = node.get("id", "unknown")
            customized_name = (node.get("metadata") or {}).get(
                "customized_name", node_id
            )

            # Warn if agent_mode_max_iterations is 0 (traditional mode) —
            # requires complex external conversation-history loop wiring
            # that the agent generator does not produce.
            input_default = node.get("input_default", {})
            max_iter = input_default.get("agent_mode_max_iterations")
            if max_iter is not None and not isinstance(max_iter, int):
                self.add_error(
                    f"OrchestratorBlock node '{customized_name}' "
                    f"({node_id}) has non-integer "
                    f"agent_mode_max_iterations={max_iter!r}. "
                    f"This field must be an integer."
                )
                valid = False
            elif isinstance(max_iter, int) and max_iter < -1:
                self.add_error(
                    f"OrchestratorBlock node '{customized_name}' "
                    f"({node_id}) has invalid "
                    f"agent_mode_max_iterations={max_iter}. "
                    f"Use -1 for infinite or a positive number for "
                    f"bounded iterations."
                )
                valid = False
            elif isinstance(max_iter, int) and max_iter > 100:
                self.add_error(
                    f"OrchestratorBlock node '{customized_name}' "
                    f"({node_id}) has agent_mode_max_iterations="
                    f"{max_iter} which is unusually high. Values above "
                    f"100 risk excessive cost and long execution times. "
                    f"Consider using a lower value (3-10) or -1 for "
                    f"genuinely open-ended tasks."
                )
                valid = False
            elif max_iter == 0:
                self.add_error(
                    f"OrchestratorBlock node '{customized_name}' "
                    f"({node_id}) has agent_mode_max_iterations=0 "
                    f"(traditional mode). The agent generator only supports "
                    f"agent mode (set to -1 for infinite or a positive "
                    f"number for bounded iterations)."
                )
                valid = False

            has_tools = any(
                link.get("source_id") == node_id
                and link.get("source_name") == "tools"
                and node_lookup.get(link.get("sink_id", ""), {}).get("block_id")
                not in non_tool_block_ids
                for link in links
            )

            if not has_tools:
                self.add_error(
                    f"OrchestratorBlock node '{customized_name}' "
                    f"({node_id}) has no downstream tool blocks connected. "
                    f"Connect at least one block to its 'tools' output so "
                    f"the AI has tools to call."
                )
                valid = False

        return valid

    def validate_mcp_tool_blocks(self, agent: AgentDict) -> bool:
        """Validate that MCPToolBlock nodes have required fields.

        Checks that each MCPToolBlock node has:
        1. A non-empty `server_url` in input_default
        2. A non-empty `selected_tool` in input_default

        Returns True if all MCPToolBlock nodes are valid, False otherwise.
        """
        valid = True
        nodes = agent.get("nodes", [])

        for node in nodes:
            if node.get("block_id") != MCP_TOOL_BLOCK_ID:
                continue

            node_id = node.get("id", "unknown")
            input_default = node.get("input_default", {})
            customized_name = (node.get("metadata") or {}).get(
                "customized_name", node_id
            )

            server_url = input_default.get("server_url")
            if not server_url:
                self.add_error(
                    f"MCPToolBlock node '{customized_name}' ({node_id}) is "
                    f"missing required 'server_url' in input_default. "
                    f"Set this to the MCP server URL "
                    f"(e.g. 'https://mcp.example.com/sse')."
                )
                valid = False

            selected_tool = input_default.get("selected_tool")
            if not selected_tool:
                self.add_error(
                    f"MCPToolBlock node '{customized_name}' ({node_id}) is "
                    f"missing required 'selected_tool' in input_default. "
                    f"Set this to the name of the MCP tool to execute."
                )
                valid = False

        return valid

    def validate(
        self,
        agent: AgentDict,
        blocks: list[dict[str, Any]],
        library_agents: list[dict[str, Any]] | None = None,
    ) -> tuple[bool, str | None]:
        """
        Comprehensive validation of an agent against available blocks.

        Returns:
            Tuple[bool, Optional[str]]: (is_valid, error_message)
            - is_valid: True if agent passes all validations, False otherwise
            - error_message: Detailed error message if validation fails, None
              if successful
        """
        logger.info("Validating agent...")
        self.errors = []

        # Build node lookup once and share across validation methods
        node_lookup = self._build_node_lookup(agent)

        checks = [
            (
                "Block existence",
                self.validate_block_existence(agent, blocks),
            ),
            (
                "Link node references",
                self.validate_link_node_references(agent),
            ),
            (
                "Required inputs",
                self.validate_required_inputs(agent, blocks),
            ),
            (
                "Data type compatibility",
                self.validate_data_type_compatibility(agent, blocks, node_lookup),
            ),
            (
                "Source output existence",
                self.validate_source_output_existence(agent, blocks, node_lookup),
            ),
            (
                "Sink input existence",
                self.validate_sink_input_existence(agent, blocks, node_lookup),
            ),
            (
                "Prompt double curly braces spaces",
                self.validate_prompt_double_curly_braces_spaces(agent),
            ),
            (
                "IO blocks",
                self.validate_io_blocks(agent, blocks),
            ),
            # Always validate AgentExecutorBlock schemas to prevent
            # frontend crashes
            (
                "AgentExecutorBlock schemas",
                self.validate_agent_executor_block_schemas(agent),
            ),
            (
                "MCP tool blocks",
                self.validate_mcp_tool_blocks(agent),
            ),
            (
                "Orchestrator blocks",
                self.validate_orchestrator_blocks(agent, node_lookup),
            ),
        ]

        # Add AgentExecutorBlock detailed validation if library_agents
        # provided
        if library_agents:
            checks.append(
                (
                    "AgentExecutorBlock references",
                    self.validate_agent_executor_blocks(agent, library_agents),
                )
            )

        all_passed = all(check[1] for check in checks)

        if all_passed:
            logger.info("Agent validation successful.")
            return True, None
        else:
            error_message = "Agent validation failed with the following errors:\n\n"
            for i, error in enumerate(self.errors, 1):
                error_message += f"{i}. {error}\n"

            logger.warning(f"Agent validation failed: {error_message}")
            return False, error_message
