from abc import ABC, ABCMeta
from functools import lru_cache
import inspect
import json
import os
from uuid import UUID
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Iterator, Optional, Tuple, Type, cast, get_origin

from vellum.workflows.references.trigger import TriggerAttributeReference
from vellum.workflows.types.utils import get_class_attr_names, infer_types
from vellum.workflows.utils.files import virtual_open
from vellum.workflows.utils.uuids import get_trigger_attribute_id, uuid4_from_hash
from vellum_ee.workflows.display.editor import NodeDisplayComment

if TYPE_CHECKING:
    from vellum.workflows.graph.graph import Graph, GraphTarget
    from vellum.workflows.state.base import BaseState


def _is_annotated(cls: Type, name: str) -> bool:
    annotations = getattr(cls, "__annotations__", {})
    annotation = annotations.get(name)
    if annotation is not None:
        if get_origin(annotation) is ClassVar:
            return False
        return True

    for base in cls.__bases__:
        if _is_annotated(base, name):
            return True

    return False


def _convert_to_relative_module_path(absolute_module_path: str, workflow_root: str) -> str:
    """
    Convert an absolute module path to a relative path from the workflow root.

    Args:
        absolute_module_path: The full module path (e.g., "workflow_id.triggers.scheduled")
        workflow_root: The workflow root module (e.g., "workflow_id")

    Returns:
        Relative module path with leading dot (e.g., ".triggers.scheduled")
    """
    if not absolute_module_path.startswith(workflow_root):
        return ""

    remaining_path = absolute_module_path[len(workflow_root) :]
    if remaining_path.startswith("."):
        return remaining_path
    else:
        return "." + remaining_path


def _find_workflow_root_with_metadata(trigger_module: str) -> Optional[str]:
    """
    Find the workflow root module by searching for metadata.json up the module hierarchy.

    Args:
        trigger_module: The trigger's module path (e.g., "workflows.my_workflow.triggers.my_trigger")

    Returns:
        The workflow root module path if found, None otherwise
    """
    module_parts = trigger_module.split(".")

    # Try searching up the module hierarchy for metadata.json
    for i in range(len(module_parts), 0, -1):
        potential_root = ".".join(module_parts[:i])
        module_dir = potential_root.replace(".", os.path.sep)
        metadata_path = os.path.join(module_dir, "metadata.json")

        # Try to open the file using virtual_open to support both regular and virtual filesystems
        # virtual_open checks BaseWorkflowFinder instances before falling back to regular open()
        try:
            file_handle = virtual_open(metadata_path)
            if file_handle is not None:
                file_handle.close()
                return potential_root
        except (FileNotFoundError, OSError):
            pass

    return None


def _get_trigger_id_from_metadata(trigger_class: Type["BaseTrigger"]) -> Optional[UUID]:
    """
    Get the trigger ID from metadata.json for a given trigger class.

    Args:
        trigger_class: The trigger class to look up

    Returns:
        The UUID from metadata.json, or None if not found
    """
    workflow_root = _find_workflow_root_with_metadata(trigger_class.__module__)
    if not workflow_root:
        return None

    trigger_path_to_id_mapping = _get_trigger_path_to_id_mapping(trigger_class.__module__)
    if not trigger_path_to_id_mapping:
        return None

    # Convert module path to relative path and append class name
    # e.g., "root_module.triggers.scheduled" + "ScheduleTrigger" -> ".triggers.scheduled.ScheduleTrigger"
    relative_module_path = _convert_to_relative_module_path(trigger_class.__module__, workflow_root)
    if not relative_module_path:
        return None

    # Append class name to get full trigger path
    relative_trigger_path = f"{relative_module_path}.{trigger_class.__qualname__}"
    return trigger_path_to_id_mapping.get(relative_trigger_path)


@lru_cache(maxsize=128)
def _get_trigger_path_to_id_mapping(module_path: str) -> Dict[str, UUID]:
    """
    Read trigger path to ID mapping from metadata.json for a given module.

    This function is cached to avoid repeated file reads. It searches up the module
    hierarchy for metadata.json and extracts the trigger_path_to_id_mapping.

    Args:
        module_path: The module path to search from (e.g., "workflows.my_workflow.triggers.my_trigger")

    Returns:
        Dictionary mapping trigger module paths to their UUIDs
    """
    try:
        # Find the workflow root that contains metadata.json
        workflow_root = _find_workflow_root_with_metadata(module_path)
        if not workflow_root:
            return {}

        # Convert module path to file path
        module_dir = workflow_root.replace(".", os.path.sep)
        metadata_path = os.path.join(module_dir, "metadata.json")

        # Use virtual_open to support both regular and virtual environments
        with virtual_open(metadata_path) as f:
            metadata_json = json.load(f)
            trigger_mapping = metadata_json.get("trigger_path_to_id_mapping", {})

            # Convert string IDs to UUIDs
            return {path: UUID(id_str) for path, id_str in trigger_mapping.items()}

    except (FileNotFoundError, json.JSONDecodeError, ValueError, KeyError):
        # If there's any error reading or parsing the file, return empty dict
        return {}


@lru_cache(maxsize=128)
def _get_trigger_attribute_id_mapping(module_path: str) -> Dict[str, UUID]:
    """
    Read trigger attribute ID mapping from metadata.json for a given module.

    This function is cached to avoid repeated file reads. It searches up the module
    hierarchy for metadata.json and extracts the trigger_attribute_id_mapping.

    Args:
        module_path: The module path to search from (e.g., "workflows.my_workflow.triggers.my_trigger")

    Returns:
        Dictionary mapping "<trigger_path>|<attribute_key>" to their UUIDs
    """
    try:
        # Find the workflow root that contains metadata.json
        workflow_root = _find_workflow_root_with_metadata(module_path)
        if not workflow_root:
            return {}

        # Convert module path to file path
        module_dir = workflow_root.replace(".", os.path.sep)
        metadata_path = os.path.join(module_dir, "metadata.json")

        # Use virtual_open to support both regular and virtual environments
        with virtual_open(metadata_path) as f:
            metadata_json = json.load(f)
            raw_mapping = metadata_json.get("trigger_attribute_id_mapping", {}) or {}

        # Convert string IDs to UUIDs, skipping invalid entries
        result: Dict[str, UUID] = {}
        for key, id_str in raw_mapping.items():
            try:
                result[key] = UUID(id_str)
            except ValueError:
                # Skip invalid UUID entries; they will fall back to hash-based IDs
                continue

        return result

    except (FileNotFoundError, json.JSONDecodeError, KeyError):
        # If there's any error reading or parsing the file, return empty dict
        return {}


def _get_trigger_attribute_id_from_metadata(trigger_class: Type["BaseTrigger"], attribute_name: str) -> Optional[UUID]:
    """
    Get the trigger attribute ID from metadata.json for a given trigger class and attribute name.

    Args:
        trigger_class: The trigger class containing the attribute
        attribute_name: The name of the attribute

    Returns:
        The UUID from metadata.json, or None if not found
    """
    workflow_root = _find_workflow_root_with_metadata(trigger_class.__module__)
    if not workflow_root:
        return None

    attribute_mapping = _get_trigger_attribute_id_mapping(trigger_class.__module__)
    if not attribute_mapping:
        return None

    # Convert module path to relative path and append class name
    # e.g., "root_module.triggers.scheduled" + "ScheduleTrigger" -> ".triggers.scheduled.ScheduleTrigger"
    relative_module_path = _convert_to_relative_module_path(trigger_class.__module__, workflow_root)
    if not relative_module_path:
        return None

    # Build the key: "<trigger_path>|<attribute_key>"
    relative_trigger_path = f"{relative_module_path}.{trigger_class.__qualname__}"
    key = f"{relative_trigger_path}|{attribute_name}"
    return attribute_mapping.get(key)


class BaseTriggerMeta(ABCMeta):
    def __new__(mcs, name: str, bases: Tuple[Type, ...], dct: Dict[str, Any]) -> Any:
        if "Display" not in dct:
            for base in reversed(bases):
                if hasattr(base, "Display"):
                    dct["Display"] = type(
                        f"{name}.Display",
                        (base.Display,),
                        {"__module__": dct["__module__"]},
                    )
                    break

        cls = super().__new__(mcs, name, bases, dct)
        trigger_class = cast(Type["BaseTrigger"], cls)

        # Set default ID based on module and class name
        trigger_class.__id__ = uuid4_from_hash(f"{trigger_class.__module__}.{trigger_class.__qualname__}")

        # Try to override with ID from metadata.json if available
        metadata_id = _get_trigger_id_from_metadata(trigger_class)
        if metadata_id:
            trigger_class.__id__ = metadata_id

        return trigger_class

    """
    Metaclass for BaseTrigger that enables class-level >> operator.

    This allows triggers to be used at the class level, similar to nodes:
        ManualTrigger >> MyNode  # Class-level, no instantiation
    """

    def __getattribute__(cls, name: str) -> Any:
        trigger_cls = cast(Type["BaseTrigger"], cls)
        if name.startswith("_"):
            return super().__getattribute__(name)

        try:
            attribute = super().__getattribute__(name)
        except AttributeError as exc:
            if _is_annotated(cls, name):
                attribute = None
            else:
                raise exc

        if inspect.isroutine(attribute) or isinstance(attribute, (property, classmethod, staticmethod)):
            return attribute

        if isinstance(attribute, TriggerAttributeReference):
            return attribute

        if name in cls.__dict__:
            return attribute

        if not _is_annotated(cls, name):
            return attribute

        types = infer_types(cls, name)
        reference = TriggerAttributeReference(name=name, types=types, instance=attribute, trigger_class=trigger_cls)
        return reference

    def __iter__(cls) -> Iterator[TriggerAttributeReference]:
        seen: Dict[str, TriggerAttributeReference] = {}

        for attr_name in get_class_attr_names(cls):
            if attr_name in seen:
                yield seen[attr_name]
                continue

            # Skip ClassVar annotations - they're not trigger attributes
            if not _is_annotated(cls, attr_name):
                continue

            attr_value = getattr(cls, attr_name)
            if isinstance(attr_value, TriggerAttributeReference):
                seen[attr_name] = attr_value
                yield attr_value

    def __rshift__(cls, other: "GraphTarget") -> "Graph":  # type: ignore[misc]
        """
        Enable Trigger class >> Node syntax (class-level only).

        Args:
            other: The target to connect to - can be a Node, Graph, or set of Nodes

        Returns:
            Graph: A graph object with trigger edges

        Examples:
            ManualTrigger >> MyNode
            ManualTrigger >> {NodeA, NodeB}
            ManualTrigger >> (NodeA >> NodeB)
        """
        from vellum.workflows.edges.trigger_edge import TriggerEdge
        from vellum.workflows.graph.graph import Graph
        from vellum.workflows.nodes.bases.base import BaseNode as BaseNodeClass

        # Cast cls to the proper type for TriggerEdge
        trigger_cls = cast(Type["BaseTrigger"], cls)

        if isinstance(other, set):
            # Trigger >> {NodeA, NodeB}
            trigger_edges = []
            graph_items = []
            for item in other:
                if isinstance(item, type) and issubclass(item, BaseNodeClass):
                    trigger_edges.append(TriggerEdge(trigger_cls, item))
                elif isinstance(item, Graph):
                    # Trigger >> {Graph1, Graph2}
                    graph_items.append(item)
                    for entrypoint in item.entrypoints:
                        trigger_edges.append(TriggerEdge(trigger_cls, entrypoint))
                else:
                    raise TypeError(
                        f"Cannot connect trigger to {type(item).__name__}. " f"Expected BaseNode or Graph in set."
                    )

            result_graph = Graph.from_trigger_edges(trigger_edges)

            for graph_item in graph_items:
                result_graph._extend_edges(graph_item.edges)
                result_graph._terminals.update(graph_item._terminals)
                for existing_trigger_edge in graph_item._trigger_edges:
                    if existing_trigger_edge not in result_graph._trigger_edges:
                        result_graph._trigger_edges.append(existing_trigger_edge)

            return result_graph

        elif isinstance(other, Graph):
            # Trigger >> Graph
            edges = [TriggerEdge(trigger_cls, entrypoint) for entrypoint in other.entrypoints]
            result_graph = Graph.from_trigger_edges(edges)
            # Also include the edges from the original graph
            result_graph._extend_edges(other.edges)
            result_graph._terminals = other._terminals
            return result_graph

        elif isinstance(other, type) and issubclass(other, BaseNodeClass):
            # Trigger >> Node
            edge = TriggerEdge(trigger_cls, other)
            return Graph.from_trigger_edge(edge)

        else:
            raise TypeError(
                f"Cannot connect trigger to {type(other).__name__}. " f"Expected BaseNode, Graph, or set of BaseNodes."
            )

    def __rrshift__(cls, other: Any) -> "Graph":
        """
        Prevent Node >> Trigger class syntax.

        Raises:
            TypeError: Always, as this operation is not allowed
        """
        raise TypeError(
            f"Cannot create edge targeting trigger {cls.__name__}. "
            f"Triggers must be at the start of a graph path, not as targets. "
            f"Did you mean: {cls.__name__} >> {other.__name__ if hasattr(other, '__name__') else other}?"
        )


class BaseTrigger(ABC, metaclass=BaseTriggerMeta):
    """
    Base class for workflow triggers - first-class graph elements.

    Triggers define how and when a workflow execution is initiated. They are integrated
    into the workflow graph using the >> operator and can connect to nodes at the class level.

    Examples:
        # Class-level usage (consistent with nodes)
        ManualTrigger >> MyNode
        ManualTrigger >> {NodeA, NodeB}
        ManualTrigger >> (NodeA >> NodeB)

    Subclass Hierarchy:
        - ManualTrigger: Explicit workflow invocation (default)
        - IntegrationTrigger: External service triggers (base for Slack, GitHub, etc.)
        - ScheduledTrigger: Time-based triggers with cron/interval schedules

    Important:
        Triggers can only appear at the start of graph paths. Attempting to create
        edges targeting triggers (Node >> Trigger) will raise a TypeError.

    Note:
        Like nodes, triggers work at the class level only. Do not instantiate triggers.
    """

    __id__: UUID

    class Display:
        """Optional display metadata for visual representation."""

        label: str = "Trigger"
        x: float = 0.0
        y: float = 0.0
        z_index: int = 0
        icon: Optional[str] = None
        color: Optional[str] = None
        comment: Optional[NodeDisplayComment] = None

    def __init__(self, **kwargs: Any):
        """
        Initialize trigger with event data.

        Args:
            **kwargs: Arbitrary keyword arguments passed during trigger instantiation.
                     Subclasses may use these to populate trigger attributes.
        """
        self._event_data = kwargs

        for key, value in kwargs.items():
            setattr(self, key, value)

    @classmethod
    def attribute_references(cls) -> Dict[str, "TriggerAttributeReference[Any]"]:
        """Return class-level trigger attribute descriptors keyed by attribute name."""

        return {reference.name: reference for reference in cls}

    def to_trigger_attribute_values(self) -> Dict["TriggerAttributeReference[Any]", Any]:
        """Materialize attribute descriptor/value pairs for this trigger instance."""

        attribute_values: Dict["TriggerAttributeReference[Any]", Any] = {}
        for reference in type(self):
            if hasattr(self, reference.name):
                attribute_values[reference] = getattr(self, reference.name)
            elif type(None) in reference.types:
                attribute_values[reference] = None
            else:
                raise AttributeError(
                    f"{type(self).__name__} did not populate required trigger attribute '{reference.name}'"
                )

        return attribute_values

    def bind_to_state(self, state: "BaseState") -> None:
        """Persist this trigger's attribute values onto the provided state."""

        if state.meta.trigger_attributes is None:
            state.meta.trigger_attributes = {}
        state.meta.trigger_attributes.update(self.to_trigger_attribute_values())

    @classmethod
    def get_attribute_id(cls, attribute_name: str) -> UUID:
        """
        Get the ID for a trigger attribute, first checking metadata.json for a stable ID,
        then falling back to a deterministic hash-based ID.

        This ensures trigger attribute IDs remain stable across serialization round-trips
        when metadata.json is present.

        Args:
            attribute_name: The name of the attribute

        Returns:
            The UUID for the attribute
        """
        # First try to get the ID from metadata.json
        metadata_id = _get_trigger_attribute_id_from_metadata(cls, attribute_name)
        if metadata_id is not None:
            return metadata_id

        # Fall back to deterministic hash-based ID
        return get_trigger_attribute_id(cls, attribute_name)

    class Config:
        """Configuration for trigger behavior. Subclasses can override."""

        pass

    def __on_workflow_initiated__(self, state: "BaseState") -> None:
        """
        Lifecycle hook called by WorkflowRunner when workflow execution starts.

        Subclasses can override to perform state initialization (e.g., appending
        user messages to chat history so nodes can reference them).

        Args:
            state: The initial workflow state
        """
        pass

    def __on_workflow_fulfilled__(self, state: "BaseState") -> None:
        """
        Lifecycle hook called by WorkflowRunner before workflow outputs are resolved.

        Subclasses can override to perform state updates that should be reflected
        in workflow outputs (e.g., appending assistant messages to chat history).

        Args:
            state: The final workflow state
        """
        pass
