"""Observability and event emission for pathway execution.

This module handles:
- Event listeners and emission
- Tool wrapping for observability
- Event creation helpers
"""

from __future__ import annotations

import contextlib
import logging
import time
from typing import TYPE_CHECKING
from uuid import uuid4

from core.common import JsonObject
from core.pathways.models.execution import PathwayEvent

if TYPE_CHECKING:
    from collections.abc import Callable

    from core.pathways.context import Context

logger = logging.getLogger(__name__)


class VMEventEmitter:
    """Event emission system for pathway execution (VM-internal)."""

    def __init__(self) -> None:
        self._listeners: list[Callable[[PathwayEvent], None]] = []

    def add_listener(self, listener: Callable[[PathwayEvent], None]) -> None:
        """Add an event listener."""
        self._listeners.append(listener)

    def emit_event(self, event: PathwayEvent) -> None:
        """Emit an event to all listeners."""
        for listener in self._listeners:
            with contextlib.suppress(Exception):
                listener(event)

    def emit(
        self,
        event_type: str,
        *,
        pathway_id: str | None = None,
        node_id: str | None = None,
        error: str | None = None,
        **data: object,
    ) -> None:
        """Emit an event with common error handling."""
        with contextlib.suppress(Exception):
            self.emit_event(
                PathwayEvent(
                    event_type=event_type,
                    pathway_id=pathway_id,
                    node_id=node_id,
                    error=error,
                    data=data,
                )
            )


def wrap_tools_for_observability(ctx: Context, emitter: VMEventEmitter) -> None:
    """Wrap ctx.tools handlers to emit tool call lifecycle PathwayEvents.

    Important: we preserve `.stream` on handlers so EventSourceNode keeps working.

    NOTE: We wrap handlers IN-PLACE in the original dict (not a copy) so that
    dynamically registered tools added later to the same dict are still visible.

    Args:
        ctx: Execution context with tools to wrap
        emitter: Event emitter for tool call events
    """
    if ctx.run._tools_wrapped:  # noqa: SLF001
        return

    # IMPORTANT: Do NOT copy - we need to preserve the reference to the original
    # tools dict so dynamically registered tools are visible.
    tools = ctx.tools or {}

    def _emit(
        event_type: str,
        *,
        tool_name: str,
        call_id: str,
        ok: bool | None = None,
        duration_ms: float | None = None,
        error: str | None = None,
        args: JsonObject | None = None,
        result: JsonObject | None = None,
    ) -> None:
        with contextlib.suppress(Exception):
            emitter.emit_event(
                PathwayEvent(
                    event_type=event_type,
                    pathway_id=getattr(ctx, "pathway_id", None) or None,
                    node_id=ctx.run._current_node_id or None,  # noqa: SLF001
                    data={
                        "execution_id": getattr(ctx, "execution_id", "") or "",
                        "tool_name": tool_name,
                        "call_id": call_id,
                        "ok": ok,
                        "duration_ms": duration_ms,
                        "args": args or {},
                        "result": result,
                    },
                    error=error,
                )
            )

    for name, handler in tools.items():
        if not callable(handler):
            continue
        if getattr(handler, "_albus_wrapped", False):
            continue

        async def _wrapped(
            call_args: JsonObject,
            ctx: Context,
            *,
            _name: str = name,
            _handler: object = handler,
        ) -> JsonObject:
            call_id = f"tc_{uuid4().hex[:10]}"
            _emit(
                "tool_called",
                tool_name=_name,
                call_id=call_id,
                args=call_args or {},
            )
            started_at = time.time()
            try:
                out = await _handler(call_args, ctx)
                dur = (time.time() - started_at) * 1000
                _emit(
                    "tool_completed",
                    tool_name=_name,
                    call_id=call_id,
                    ok=True,
                    duration_ms=dur,
                    result=out,
                )
                return out
            except Exception as e:
                dur = (time.time() - started_at) * 1000
                _emit(
                    "tool_failed",
                    tool_name=_name,
                    call_id=call_id,
                    ok=False,
                    duration_ms=dur,
                    error=str(e),
                )
                raise

        # Preserve streaming attribute if present.
        if hasattr(handler, "stream"):
            _wrapped.stream = handler.stream
        _wrapped._albus_wrapped = True  # noqa: SLF001
        tools[name] = _wrapped

    # NOTE: We modified tools in-place (no copy), so no need to reassign ctx.tools
    ctx.run._tools_wrapped = True  # noqa: SLF001


# Backward-compatible alias
EventEmitter = VMEventEmitter
