# Copyright 2025 Tenro.ai
# SPDX-License-Identifier: Apache-2.0

"""Active construct for mocking agent tool calls and tracking lifecycle spans.

Provides lifecycle tracking, simulation helpers, provider-aware LLM mocking,
and verification APIs for tests.
"""

from __future__ import annotations

import functools
import time
import uuid
from collections.abc import Callable, Generator
from contextlib import contextmanager
from typing import Any

from tenro.construct.http import HttpInterceptor
from tenro.construct.http.builders import ProviderSchemaFactory
from tenro.construct.in_memory_store import EventStore
from tenro.construct.simulate.helpers import (
    execute_simulation_logic,
    get_response_by_index,
    normalize_result_sequence,
    validate_simulation_params,
)
from tenro.construct.simulate.rule import SimulationRule
from tenro.construct.simulate.tracker import SimulationTracker
from tenro.construct.verify import ConstructVerifications
from tenro.core import context
from tenro.core.lifecycle_manager import LifecycleManager
from tenro.core.reconstruction import reconstruct_spans
from tenro.core.response_types import ProviderResponse
from tenro.core.spans import AgentRun, LLMCall, ToolCall


def _validate_and_resolve_target(target: Any, expected_type: str) -> str:
    """Validate callable target and return path for simulation.

    Supports three input types:
    1. String path: `module.Class.method` or `module.func`
    2. Unbound class method: `Class.method` (third-party libraries - no decorator needed!)
    3. Decorated function: `@link_tool`/`@link_agent` decorated function (module-level functions)

        Args:
            `target`: Target to validate (string path or callable object).
            `expected_type`: Expected link type (`tool`, `agent`, `llm`).
    Returns:
        String path to use for simulation.

    Raises:
        ValueError: If target is undecorated module-level function (Import Trap risk).
    """
    # Case 1: String path - use directly (no validation needed)
    if not callable(target) or isinstance(target, str):
        return str(target)

    # Case 2: Unbound class method (e.g., ChatOpenAI.predict)
    # Must check BEFORE decorator check - order matters!

    # Handle @classmethod (bound method descriptor with __func__)
    if hasattr(target, "__func__"):
        qualname = getattr(target.__func__, "__qualname__", "")
        if "." in qualname and "<lambda>" not in qualname and "<locals>" not in qualname:
            # It's an unbound class method - extract full path
            module = getattr(target.__func__, "__module__", None)
            if module:
                return f"{module}.{qualname}"
            return qualname

    # Handle instance methods and @staticmethod (regular functions)
    qualname = getattr(target, "__qualname__", "")
    if "." in qualname and "<lambda>" not in qualname and "<locals>" not in qualname:
        # It's an unbound class method - extract full path
        module = getattr(target, "__module__", None)
        if module:
            return f"{module}.{qualname}"
        return qualname

    # Case 3: Decorated function - must have metadata (module-level functions only)
    if not hasattr(target, "_tenro_linked_type"):
        func_name = getattr(target, "__name__", str(target))
        msg = (
            f"Cannot simulate '{func_name}' - function is not "
            f"decorated with @link_{expected_type}.\n\n"
            f"The Import Trap: Mocking undecorated MODULE-LEVEL functions "
            f"fails silently when imported with 'from module import func'. "
            f"The mock patches the source module, but your code holds a "
            f"separate reference.\n\n"
            f"Fix: Add @link_{expected_type} decorator to '{func_name}':\n"
            f"  from tenro import link_{expected_type}\n\n"
            f"  @link_{expected_type}('{func_name}')\n"
            f"  def {func_name}(...):\n"
            f"      ...\n\n"
            f"Alternatively, use a module path string:\n"
            f"  construct.simulate_{expected_type}("
            f"'myapp.{func_name}', result=...)\n\n"
            f"Note: CLASS METHODS don't need decorators! Pass them directly:\n"
            f"  from third_party import MyClass\n"
            f"  construct.simulate_{expected_type}(MyClass.method, result=...)"
        )
        raise ValueError(msg)

    # Extract and validate decorator metadata
    link_type: str = target._tenro_linked_type
    link_name: str = target._tenro_linked_name

    # Validate it's the correct type
    if link_type != expected_type:
        msg = (
            f"Cannot use simulate_{expected_type}() with @link_{link_type} "
            f"decorated function.\n"
            f"Use construct.simulate_{link_type}() instead."
        )
        raise ValueError(msg)

    return link_name


def _parse_dotted_path(path: str) -> tuple[Any, str]:
    """Parse dotted path into (container_object, attribute_name) for patching.

    Handles both module-level functions and class methods:
    - `module.func` -> (module, "func")
    - `module.Class.method` -> (Class, "method")
    - `module.Outer.Inner.method` -> (Inner, "method")

    Args:
        `path`: Dotted path to parse (e.g., `langchain.chat_models.ChatOpenAI.predict`).
    Returns:
        Tuple of (container_object, attribute_name) for patching.

    Raises:
        ValueError: If module not imported.
        AttributeError: If path is invalid.

    Examples:
        >>> container, attr = _parse_dotted_path("os.path.join")
        >>> # container = os.path, attr = "join"
        >>> original = getattr(container, attr)
        >>> setattr(container, attr, mock_func)
    """
    import sys

    if "." not in path:
        raise ValueError(f"Path must contain at least one dot, got: {path}")

    parts = path.split(".")

    # Start with the module
    module_name = parts[0]
    module = sys.modules.get(module_name)
    if module is None:
        raise ValueError(
            f"Module '{module_name}' not imported. "
            f"Import the module before mocking: import {module_name}"
        )

    # Walk down the path to find the container object
    container = module
    for part in parts[1:-1]:
        if not hasattr(container, part):
            raise AttributeError(
                f"'{container}' has no attribute '{part}' (while parsing path '{path}')"
            )
        container = getattr(container, part)

    # The final part is the attribute to patch
    attribute_name = parts[-1]

    return container, attribute_name


class Construct:
    """Active construct for mocking tools and tracking operation lifecycle.

    Tracks LLM calls and tool calls as mutable span objects that update in
    real-time, providing a simple API for testing.

    Features:
        - Mock tool calls with smart defaults
        - Track LLM and tool calls as mutable span objects
        - Provider helpers for OpenAI, Anthropic, and Gemini
        - Expressive assertion API for readable tests

    Examples:
        >>> # Pytest usage (recommended)
        >>> def test_my_agent(construct):  # Auto-activated by fixture!
        ...     construct.simulate_tool("search", returns=["doc1", "doc2"])
        ...     result = my_agent.run()
        ...     construct.verify_tool("search", times=1)
        >>>
        >>> # LLM mocking
        >>> def test_llm_agent(construct):
        ...     construct.simulate_llm(
        ...         "openai.chat.completions.create",
        ...         responses="Hello! How can I help?"
        ...     )
        ...     result = my_agent.run()
        ...     assert construct.llm_calls[0].response == "Hello! How can I help?"
        >>>
        >>> # Manual activation (non-pytest or multi-step tests)
        >>> construct = Construct()
        >>> construct.simulate_tool("search", returns=["doc1"])
        >>> with construct:
        ...     result = my_agent.run()
        >>> construct.verify_tool("search", times=1)
    """

    def __init__(self, respx_mock: Any) -> None:
        """Initialize construct for event-based tracking and mocking.

        The construct wraps user code via context manager to track LLM and
        tool calls. User code runs inside the `with construct:` block.

        Args:
            `respx_mock`: respx.MockRouter from the respx_mock pytest fixture.
        """
        # Event-based storage for span events
        self._event_store = EventStore()
        self._trace_id: str | None = None

        # Internal infrastructure
        self._lifecycle = LifecycleManager(event_store=self._event_store)
        self._http_interceptor = HttpInterceptor(mock=respx_mock, on_call=self._on_http_call)

        # Simulation infrastructure
        self._simulations: dict[str, SimulationRule] = {}
        self._originals: dict[str, Any] = {}  # Track originals for restoration
        self._patches: list[Any] = []  # Track unittest.mock patches
        self._active: bool = False  # Track if context manager is active
        self._http_mocking_enabled: bool = False  # Track if HTTP interception is active
        self._simulation_tracker = SimulationTracker()

        # Assertions will be created lazily after reconstruction
        self._verifications: ConstructVerifications | None = None

    # ========================================================================
    # Data Access Properties (Reconstruct from Events)
    # ========================================================================

    @property
    def agent_runs(self) -> list[AgentRun]:
        """Get all agent runs as a flat list (reconstructed from events).

        Returns:
            Flat list of all agent runs (includes nested agents).

        Examples:
            >>> agents = construct.agent_runs
            >>> assert len(agents) == 3  # Manager, Researcher, Writer
            >>> assert agents[0].name == "Manager"
            >>> assert agents[1].parent_agent_id == agents[0].id
        """
        # Get hierarchical reconstruction first
        root_agents = self._get_root_agent_runs()

        # Flatten to list (depth-first traversal)
        flat_agents: list[AgentRun] = []

        def collect_agents(agent: AgentRun) -> None:
            flat_agents.append(agent)
            for span in agent.spans:
                if isinstance(span, AgentRun):
                    collect_agents(span)

        for root in root_agents:
            collect_agents(root)

        return flat_agents

    def _get_root_agent_runs(self) -> list[AgentRun]:
        """Get root agent runs.

        Returns:
            List of root agent runs with populated .spans field.
        """
        events = self._event_store.get_all_events()
        return reconstruct_spans(events)

    @property
    def llm_calls(self) -> list[LLMCall]:
        """Get LLM calls across all agents and orphan calls.

        Returns:
            Flat list of LLM calls (includes orphan calls without agent parent).

        Examples:
            >>> llm_calls = construct.llm_calls
            >>> assert len(llm_calls) == 3
        """
        # Get LLM calls from root agent hierarchies (recursive traversal)
        root_agents = self._get_root_agent_runs()
        agent_llm_calls = [
            llm for agent in root_agents for llm in agent.get_llm_calls(recursive=True)
        ]

        # Also get orphan LLM calls (those without an agent parent)
        events = self._event_store.get_all_events()
        from tenro.core.reconstruction import _create_typed_spans, _group_events_by_span

        span_map = _group_events_by_span(events)
        typed_spans = _create_typed_spans(span_map)

        orphan_llm_calls = [
            span
            for span in typed_spans.values()
            if isinstance(span, LLMCall) and span.agent_id is None
        ]

        return agent_llm_calls + orphan_llm_calls

    @property
    def tool_calls(self) -> list[ToolCall]:
        """Get tool calls across all agents and orphan calls.

        Returns:
            Flat list of tool calls (includes orphan calls without agent parent).

        Examples:
            >>> tool_calls = construct.tool_calls
            >>> assert len(tool_calls) == 2
        """
        # Get tool calls from root agent hierarchies (recursive traversal)
        root_agents = self._get_root_agent_runs()
        agent_tool_calls = [
            tool for agent in root_agents for tool in agent.get_tool_calls(recursive=True)
        ]

        # Also get orphan tool calls (those without an agent parent)
        events = self._event_store.get_all_events()
        from tenro.core.reconstruction import _create_typed_spans, _group_events_by_span

        span_map = _group_events_by_span(events)
        typed_spans = _create_typed_spans(span_map)

        orphan_tool_calls = [
            span
            for span in typed_spans.values()
            if isinstance(span, ToolCall) and span.agent_id is None
        ]

        return agent_tool_calls + orphan_tool_calls

    def _stop_http_interceptor(self) -> None:
        """Stop HTTP interception."""
        if not self._http_mocking_enabled:
            return
        self._http_interceptor.stop()
        self._http_mocking_enabled = False

    def _check_unused_llm_simulations(self) -> None:
        """Check for unused mocks and raise errors immediately."""
        from tenro.construct.http.interceptor import PROVIDER_ENDPOINTS

        try:
            self._simulation_tracker.validate(
                llm_calls=self.llm_calls,
                supported_providers=list(PROVIDER_ENDPOINTS.keys()),
            )
        finally:
            self._simulation_tracker.reset()

    def _stop_patches(self) -> None:
        """Stop active patches."""
        for patcher in self._patches:
            patcher.stop()

    def _restore_originals(self) -> None:
        """Restore patched functions."""
        for tool_name, original in self._originals.items():
            container, attr_name = _parse_dotted_path(tool_name)
            setattr(container, attr_name, original)

    def _clear_mock_state(self) -> None:
        """Clear patching state while preserving trace data."""
        self._patches.clear()
        self._originals.clear()

    # ========================================================================
    # User-Facing Link API (Context Managers)
    # ========================================================================

    @contextmanager
    def link_agent(
        self, name: str, input_data: Any = None, **kwargs: Any
    ) -> Generator[AgentRun, None, None]:
        """Link an agent execution with automatic lifecycle management.

        Creates an AgentRun span with automatic parent tracking, latency
        calculation, and stack-safe cleanup.

        Args:
            `name`: Agent name.
            `input_data`: Input data for the agent.
            `**kwargs`: Additional keyword arguments passed to the agent.

        Yields:
            Mutable AgentRun span for direct modification.

        Examples:
            >>> with construct.link_agent("Manager", input_data="task") as agent:
            ...     # Nested operations automatically get Manager as parent
            ...     agent.output_data = "completed"
            >>> with construct.link_agent("RiskAgent", threshold=0.8) as agent:
            ...     # Agent called with threshold parameter
            ...     agent.output_data = "low risk"
        """
        span = AgentRun(
            id=str(uuid.uuid7()),
            trace_id=str(uuid.uuid7()),
            start_time=time.time(),
            name=name,
            input_data=input_data,
            kwargs=kwargs,
        )
        with self._lifecycle.start_span(span):
            yield span

    @contextmanager
    def link_llm(
        self,
        provider: str,
        model: str | None = None,
        *,
        caller_signature: str | None = None,
        caller_location: str | None = None,
    ) -> Generator[LLMCall, None, None]:
        """Link an LLM call with automatic lifecycle management.

        Creates an LLMCall span with automatic parent tracking, latency
        calculation, and stack-safe cleanup.

        Args:
            `provider`: LLM provider (e.g., `openai`, `anthropic`).
            `model`: Model identifier (e.g., `gpt-4`, `claude-3`).
            `caller_signature`: Function signature for error messages (set by `@link_llm`).
            `caller_location`: File location for error messages (set by `@link_llm`).
        Yields:
            Mutable LLMCall span for direct modification.

        Examples:
            >>> with construct.link_llm("openai", model="gpt-4") as llm:
            ...     llm.messages = [{"role": "user", "content": "test"}]
            ...     llm.response = "Hello!"
        """
        span = LLMCall(
            id=str(uuid.uuid7()),
            trace_id=str(uuid.uuid7()),
            start_time=time.time(),
            provider=provider,
            messages=[],
            caller_signature=caller_signature,
            caller_location=caller_location,
        )
        with self._lifecycle.start_span(span):
            yield span

    @contextmanager
    def link_tool(
        self, tool_name: str, *args: Any, **kwargs: Any
    ) -> Generator[ToolCall, None, None]:
        """Link a tool call with automatic lifecycle management.

        Creates a ToolCall span with automatic parent tracking, latency
        calculation, and stack-safe cleanup.

        Args:
            `tool_name`: Name of the tool being called.
            `*args`: Positional arguments passed to the tool.
            `**kwargs`: Keyword arguments passed to the tool.

        Yields:
            Mutable ToolCall span for direct modification.

        Examples:
            >>> with construct.link_tool("search") as tool:
            ...     tool.result = ["doc1", "doc2"]
            >>> with construct.link_tool("search", "query", limit=10) as tool:
            ...     tool.result = ["doc1", "doc2"]
            >>> with construct.link_tool("api_call", "POST", timeout=30) as tool:
            ...     tool.result = {"status": 200}
        """
        span = ToolCall(
            id=str(uuid.uuid7()),
            trace_id=str(uuid.uuid7()),
            start_time=time.time(),
            tool_name=tool_name,
            args=args,
            kwargs=kwargs,
        )
        with self._lifecycle.start_span(span):
            yield span

    # ========================================================================
    # Mocking Methods
    # ========================================================================

    def simulate_tool(
        self,
        target: str | Callable[..., Any],
        result: Any = None,
        results: list[Any] | None = None,
        side_effect: Callable[..., Any] | None = None,
    ) -> None:
        """Mock an agent tool with lifecycle tracking.

        Automatically creates ToolCall spans for tracking.

        Args:
            `target`: Tool module path (e.g., `myapp.tools.search`) or function object.
            `result`: Single static value to return (most common case).
            `results`: List of values for sequential calls. Can include `Exception`
                objects which will be raised when reached.
            `side_effect`: Callable for dynamic behavior. Receives tool arguments.

        Raises:
            ValueError: If multiple result parameters are provided.

        Examples:
            >>> # Single result (most common)
            >>> construct.simulate_tool("myapp.tools.search", result="doc1")
            >>>
            >>> # Sequential results with exceptions
            >>> construct.simulate_tool(
            ...     "myapp.tools.api_call",
            ...     results=[{"status": "ok"}, TimeoutError("Connection lost"), {"status": "ok"}],
            ... )
            >>>
            >>> # Dynamic behavior
            >>> def weather_logic(city: str):
            ...     return {"temp": 72 if city == "SF" else 65}
            >>> construct.simulate_tool("myapp.tools.get_weather", side_effect=weather_logic)
            >>>
            >>> # Function object (refactor-safe)
            >>> from myapp.tools import search
            >>> construct.simulate_tool(search, result="doc1")
        """
        # Validate mutually exclusive parameters
        validate_simulation_params(result, results, side_effect)

        # Validate and resolve target (handles decorator validation)
        function_path = _validate_and_resolve_target(target, "tool")

        # Normalize result/results to sequence
        result_sequence = normalize_result_sequence(result, results)

        def _lifecycle_wrapper(*args: Any, **kwargs: Any) -> Any:
            """Wrapper that triggers lifecycle events for tool calls."""
            span = ToolCall(
                id=str(uuid.uuid7()),
                trace_id=str(uuid.uuid7()),
                start_time=time.time(),
                tool_name=function_path,
                args=args,
                kwargs=kwargs,
            )

            with self._lifecycle.start_span(span):
                # Execute simulation logic (side_effect, result_sequence, or None)
                value = execute_simulation_logic(
                    side_effect, result_sequence, function_path, args, kwargs
                )

                # Set result on span
                span.result = value
                return value

        rule = SimulationRule(side_effect=_lifecycle_wrapper)
        self._simulations[function_path] = rule

        # If context manager is already active, apply patch immediately
        if self._active:
            self._apply_patch(function_path)

    def simulate_agent(
        self,
        target: str | Callable[..., Any],
        result: Any = None,
        results: list[Any] | None = None,
        side_effect: Callable[..., Any] | None = None,
    ) -> None:
        """Mock an agent with lifecycle tracking.

        Agents are high-level workflows that orchestrate tools and LLMs.
        This method allows testing agent behavior in isolation.

        Args:
            `target`: Agent module path (e.g., `myapp.agents.planner`) or function object.
            `result`: Single static value to return (most common case).
            `results`: List of values for sequential calls. Can include `Exception`
                objects which will be raised when reached.
            `side_effect`: Callable for dynamic behavior. Receives agent arguments.

        Raises:
            ValueError: If multiple result parameters are provided.

        Examples:
            >>> # Single result (most common)
            >>> construct.simulate_agent("myapp.agents.planner", result={"plan": "step1"})
            >>>
            >>> # Sequential results with exceptions
            >>> construct.simulate_agent(
            ...     "myapp.agents.researcher",
            ...     results=[
            ...         {"findings": "data1"},
            ...         TimeoutError("Research timeout"),
            ...         {"findings": "retry_data"},
            ...     ],
            ... )
            >>>
            >>> # Dynamic behavior
            >>> def agent_logic(query: str):
            ...     return {"result": f"processed_{query}"}
            >>> construct.simulate_agent("myapp.agents.processor", side_effect=agent_logic)
            >>>
            >>> # Function object (refactor-safe)
            >>> from myapp.agents import planner
            >>> construct.simulate_agent(planner, result={"plan": "step1"})
        """
        # Validate mutually exclusive parameters
        validate_simulation_params(result, results, side_effect)

        # Validate and resolve target (handles decorator validation)
        agent_path = _validate_and_resolve_target(target, "agent")

        # Normalize result/results to sequence
        result_sequence = normalize_result_sequence(result, results)

        def _lifecycle_wrapper(*args: Any, **kwargs: Any) -> Any:
            """Wrapper that triggers lifecycle events for agent runs."""
            span = AgentRun(
                id=str(uuid.uuid7()),
                trace_id=str(uuid.uuid7()),
                start_time=time.time(),
                name=agent_path,
                input_data=kwargs if kwargs else (args[0] if args else None),
                kwargs=kwargs if kwargs else {},
            )

            with self._lifecycle.start_span(span):
                # Execute simulation logic (side_effect, result_sequence, or None)
                value = execute_simulation_logic(
                    side_effect, result_sequence, agent_path, args, kwargs
                )

                # Set result on span
                span.output_data = value
                return value

        rule = SimulationRule(side_effect=_lifecycle_wrapper)
        self._simulations[agent_path] = rule

        # If context manager is already active, apply patch immediately
        if self._active:
            self._apply_patch(agent_path)

    def _apply_patch(self, target_path: str) -> None:
        """Apply a single patch for a simulation target.

        This method is used both by __enter__ (for batch patching) and by
        simulate_* methods (for immediate patching when called after __enter__).

        Args:
            `target_path`: The dotted path to the function/method to patch.
        """
        # Skip if already patched
        if target_path in self._originals:
            return

        # Parse dotted path (handles both "module.func" and "module.Class.method")
        container, attr_name = _parse_dotted_path(target_path)

        # Get original function/method
        if not hasattr(container, attr_name):
            msg = f"'{container}' has no attribute '{attr_name}'"
            raise AttributeError(msg)

        original = getattr(container, attr_name)
        self._originals[target_path] = original

        # Get mock rule (already has lifecycle wrapper embedded)
        rule = self._simulations[target_path]

        # Create simple wrapper that calls the mock's side_effect
        @functools.wraps(original)
        def make_wrapper(mock_rule: SimulationRule) -> Callable[..., Any]:
            def wrapper(*args: Any, **kwargs: Any) -> Any:
                if mock_rule.side_effect:
                    side_effect = mock_rule.side_effect
                    if callable(side_effect):
                        return side_effect(*args, **kwargs)
                    elif isinstance(side_effect, list):
                        # For lists, the lifecycle wrapper handles popping
                        if side_effect:
                            return side_effect[0](*args, **kwargs)
                    elif isinstance(side_effect, BaseException):
                        raise side_effect
                    elif isinstance(side_effect, type) and issubclass(side_effect, BaseException):
                        raise side_effect()
                    else:
                        raise TypeError("side_effect must be callable, list, or exception")
                return mock_rule.returns_value

            return wrapper

        setattr(container, attr_name, make_wrapper(rule))

    def __enter__(self) -> Construct:
        """Activate simulations by patching simulated tools/LLMs/agents.

        Note: With lifecycle wrappers, simulations are already wrapped with
        lifecycle tracking. This method just patches module-level functions
        or class methods with the wrapped versions.

        Also registers this construct as the active construct for decorator access.
        Starts HTTP interception if HTTP mocking is enabled.

        After __enter__ completes, any subsequent simulate_* calls will apply
        patches immediately (supports pytest fixtures).
        """
        # Register as active construct for decorators
        from tenro.linking.decorators import _set_active_construct

        _set_active_construct(self)

        # Mark as active BEFORE patching so simulate_* methods know to patch immediately
        self._active = True

        # Start HTTP interception if enabled
        if self._http_mocking_enabled:
            self._http_interceptor.start()

        # Apply patches for all existing simulations
        for target_path in self._simulations:
            self._apply_patch(target_path)

        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_value: BaseException | None,
        exc_traceback: Any,
    ) -> None:
        """Restore original functions/methods and cleanup state.

        Harness errors (e.g., unused mocks) are raised immediately.

        Note:
            Never suppresses incoming exceptions from test body.
        """
        # Mark as inactive first
        self._active = False

        # Unregister as active construct
        from tenro.linking.decorators import _set_active_construct

        _set_active_construct(None)

        self._stop_http_interceptor()
        self._check_unused_llm_simulations()
        self._stop_patches()
        self._restore_originals()
        self._clear_mock_state()

    # ========================================================================
    # Expressive Verification API
    # ========================================================================

    def _get_verifications(self) -> ConstructVerifications:
        """Get verifications instance (lazy initialization with reconstructed spans)."""
        # Always reconstruct from events for latest state
        # Pass full lists including orphan spans
        # Note: Use _get_root_agent_runs for hierarchical filtering
        return ConstructVerifications(
            agent_runs=self._get_root_agent_runs(),
            llm_calls=self.llm_calls,
            tool_calls=self.tool_calls,
        )

    # ========================================================================
    # Verification API
    # ========================================================================

    # Tool Verifications
    def verify_tool(
        self,
        target: str,
        called_with: dict[str, Any] | None = None,
        *,
        times: int | None = None,
        output: Any = None,
        output_contains: str | None = None,
        output_exact: Any = None,
        call_index: int | None = 0,
        **kwargs: Any,
    ) -> None:
        """Verify tool was called with optional argument and output matching."""
        self._get_verifications().verify_tool(
            target,
            called_with,
            times=times,
            output=output,
            output_contains=output_contains,
            output_exact=output_exact,
            call_index=call_index,
            **kwargs,
        )

    def verify_tool_never(self, target: str) -> None:
        """Verify tool was never called."""
        self._get_verifications().verify_tool_never(target)

    def verify_tool_sequence(self, expected_sequence: list[str]) -> None:
        """Verify tools were called in a specific order."""
        self._get_verifications().verify_tool_sequence(expected_sequence)

    def verify_tools(
        self,
        count: int | None = None,
        min: int | None = None,
        max: int | None = None,
        target: str | None = None,
    ) -> None:
        """Verify tool calls with optional count/range and name filter."""
        self._get_verifications().verify_tools(count=count, min=min, max=max, target=target)

    # Agent Verifications
    def verify_agent(
        self,
        target: str,
        called_with: dict[str, Any] | None = None,
        *,
        times: int | None = None,
        output: Any = None,
        output_contains: str | None = None,
        output_exact: Any = None,
        call_index: int | None = 0,
        **kwargs: Any,
    ) -> None:
        """Verify agent was called with optional argument and output matching."""
        self._get_verifications().verify_agent(
            target,
            called_with,
            times=times,
            output=output,
            output_contains=output_contains,
            output_exact=output_exact,
            call_index=call_index,
            **kwargs,
        )

    def verify_agent_never(self, target: str) -> None:
        """Verify agent was never called."""
        self._get_verifications().verify_agent_never(target)

    def verify_agent_sequence(self, expected_sequence: list[str]) -> None:
        """Verify agents were called in a specific order."""
        self._get_verifications().verify_agent_sequence(expected_sequence)

    def verify_agents(
        self,
        count: int | None = None,
        min: int | None = None,
        max: int | None = None,
        target: str | None = None,
    ) -> None:
        """Verify agent calls with optional count/range and name filter."""
        self._get_verifications().verify_agents(count=count, min=min, max=max, target=target)

    # LLM Verifications
    def verify_llm(
        self,
        target: str | None = None,
        provider: str | None = None,
        *,
        times: int | None = None,
        output: Any = None,
        output_contains: str | None = None,
        output_exact: Any = None,
        where: str | None = None,
        call_index: int | None = None,
    ) -> None:
        """Verify LLM was called with optional output checking."""
        self._get_verifications().verify_llm(
            target,
            provider,
            times=times,
            output=output,
            output_contains=output_contains,
            output_exact=output_exact,
            where=where,
            call_index=call_index,
        )

    def verify_llm_never(self, target: str | None = None, provider: str | None = None) -> None:
        """Verify LLM was never called."""
        self._get_verifications().verify_llm_never(target, provider)

    def verify_llms(
        self,
        count: int | None = None,
        min: int | None = None,
        max: int | None = None,
        target: str | None = None,
        provider: str | None = None,
    ) -> None:
        """Verify LLM calls with optional count/range and target/provider filter."""
        self._get_verifications().verify_llms(
            count=count, min=min, max=max, target=target, provider=provider
        )

    # ========================================================================
    # Provider-Aware LLM Mocking
    # ========================================================================
    #
    # Responses are shaped to match known provider SDKs (OpenAI/Anthropic/Gemini)
    # when the target is a provider API, and default to semantic dicts for
    # custom wrappers. This keeps tests realistic without requiring extra
    # user-side adapters.

    # ========================================================================
    # Provider Schema Helpers
    # ========================================================================

    def _detect_provider(self, target: str) -> str:
        """Auto-detect provider from target path."""
        return ProviderSchemaFactory.detect_provider(target)

    def _create_provider_response(
        self, provider: str, content: str, **kwargs: Any
    ) -> ProviderResponse:
        """Create a provider-specific response object."""
        return ProviderSchemaFactory.create_response(provider, content, **kwargs)

    def simulate_llm(
        self,
        target: str | Callable[..., Any] | None = None,
        provider: str | None = None,
        *,
        response: str | None = None,
        responses: str | list[str | Exception] | None = None,
        model: str | None = None,
        tools: list[str | dict[str, Any]] | None = None,
        use_http: bool | None = None,
        optional: bool = False,
        **response_kwargs: Any,
    ) -> None:
        """Mock LLM calls with smart provider detection and lifecycle tracking.

        This method keeps the API simple while preserving provider-accurate
        responses when possible:
        - Accepts plain text responses
        - Auto-detects known provider SDK targets
        - Uses HTTP interception for known providers (default without custom target)
        - Falls back to setattr patching for custom wrappers
        - Creates LLMCall spans for lifecycle tracking

        HTTP interception ensures the provider SDK parses JSON and yields
        real SDK types (e.g., `TextBlock`, `ChatCompletion`).

        Supports text generation APIs only. Supported targets include:
        - OpenAI Chat Completions (`openai.chat.completions.create`)
        - Anthropic Messages (`anthropic.resources.messages.Messages.create`)
        - Gemini GenerateContent (`google.genai.models.Models.generate_content`)

        For other provider APIs (embeddings, audio, images, assistants),
        register a custom provider schema or use a custom target.

        Args:
            `target`: Optional Python function path to mock (e.g.,
                `openai.chat.completions.create`). If not provided, uses the
                default target for the specified provider. When `target` is
                provided, setattr patching is used (not HTTP interception).
            `provider`: LLM provider (`openai`, `anthropic`, `gemini`, `custom`).
                Auto-detected from `target` if not provided. Required when
                `target` is not provided.
            `response`: Single string response (most common case).
            `responses`: List of responses for sequential calls. Can include
                `Exception` objects which will be raised when reached.
            `model`: Model identifier (e.g., `gpt-4`, `claude-3-opus`).
                Overrides provider default model name.
            `tools`: Tool calls to include in the response. Use simplified or
                full format:
                - Simplified (no args): [`tool_name_1`, `tool_name_2`]
                - With arguments: [{"name": "tool1", "arguments": {"key": "value"}}]
                - Full spec: [{"id": "call_123", "type": "function", "function": {...}}]
                This generates the LLM's decision to call these tools.
            `use_http`: Force HTTP interception (`True`) or setattr patching (`False`).
                Defaults to `True` for known providers when no custom target is
                specified.
            `optional`: If `True`, this mock won't cause `UnusedSimulationError`
                if unused. Use for branch coverage or optional paths. Defaults
                to `False`.
            `**response_kwargs`: Provider-specific options:
                - `token_usage`: Dict with token counts (e.g., {"total_tokens": 50})
                - `finish_reason`: Override finish reason (`stop`, `length`, `tool_calls`)
                - `stop_reason`: Anthropic-specific stop reason
                - `safety_ratings`: Gemini-specific safety ratings
                - (Other provider-specific parameters)

        Raises:
            ValueError: If both `response` and `responses` are provided.

        Examples:
            >>> # Single response (most common, uses HTTP interception)
            >>> construct.simulate_llm(provider="anthropic", response="Hello!")
            >>>
            >>> # Multi-turn conversation
            >>> construct.simulate_llm(
            ...     provider="anthropic",
            ...     responses=["Turn 1", "Turn 2", "Turn 3"],
            ... )
            >>>
            >>> # Custom target (uses setattr patching)
            >>> construct.simulate_llm(
            ...     target="myapp.llm_wrapper.call",
            ...     provider="anthropic",
            ...     response="Hello!",
            ... )
            >>>
            >>> # Force setattr patching (returns dicts, not SDK types)
            >>> construct.simulate_llm(
            ...     provider="anthropic",
            ...     response="Hello!",
            ...     use_http=False,
            ... )
            >>>
            >>> # Optional mock for branch coverage
            >>> construct.simulate_llm(
            ...     provider="openai",
            ...     response="Fallback response",
            ...     optional=True,
            ... )
        """
        # Validate mutually exclusive parameters
        if response is not None and responses is not None:
            raise ValueError("Only one of 'response' or 'responses' can be provided")

        # Normalize: convert single response to list
        if response is not None:
            response_sequence: list[str | Exception] = [response]
        elif responses is not None:
            response_sequence = [responses] if isinstance(responses, str) else responses.copy()
        else:
            raise ValueError("Either 'response' or 'responses' must be provided")

        # Determine if we should use HTTP interception
        # Default: use HTTP for known providers when no custom target is specified
        from tenro.construct.http.interceptor import PROVIDER_ENDPOINTS

        custom_target_provided = target is not None

        # Resolve provider early for HTTP decision
        if provider is None and target is not None:
            if callable(target) and not isinstance(target, str):
                qualname = getattr(target, "__qualname__", "")
                module = getattr(target, "__module__", "")
                temp_path = f"{module}.{qualname}" if module and qualname else str(target)
            else:
                temp_path = str(target)
            provider = self._detect_provider(temp_path)
        elif provider is None:
            raise ValueError(
                "Either 'target' or 'provider' must be specified.\n"
                "Examples:\n"
                "  - construct.simulate_llm(provider='openai', response='Hi')\n"
                "  - construct.simulate_llm(\n"
                "        target='openai.chat.completions.create', response='Hi'\n"
                "    )"
            )

        # Decide whether to use HTTP interception
        if use_http is None:
            # Auto-detect: use HTTP when provider-only mode with known provider
            use_http = not custom_target_provided and provider in PROVIDER_ENDPOINTS

        if use_http and provider in PROVIDER_ENDPOINTS:
            # Use HTTP interception (respx) - SDK parses JSON → real types
            self._simulate_llm_http(
                provider=provider,
                response_sequence=response_sequence,
                model=model,
                tools=tools,
                optional=optional,
                **response_kwargs,
            )
        else:
            # Fall back to setattr patching (returns dicts)
            self._simulate_llm_setattr(
                target=target,
                provider=provider,
                response_sequence=response_sequence,
                model=model,
                tools=tools,
                **response_kwargs,
            )

    def _on_http_call(
        self,
        provider: str,
        messages: list[dict[str, Any]],
        model: str | None,
        response_text: str,
    ) -> None:
        """Callback invoked by HttpInterceptor for each intercepted request.

        Creates an LLMCall span to track the call in lifecycle.
        """
        self._simulation_tracker.mark_triggered(provider)

        # Mark current @link_llm span as having made HTTP call
        current = context.get_current_span()
        if isinstance(current, LLMCall):
            self._simulation_tracker.mark_llm_http_call(current.id)
        span = LLMCall(
            id=str(uuid.uuid7()),
            trace_id=str(uuid.uuid7()),
            start_time=time.time(),
            provider=provider,
            messages=messages,
            response=response_text,
        )
        # Record completed span - context manager emits start/end events
        with self._lifecycle.start_span(span):
            pass  # Span already has response set, just record it

    def _simulate_llm_http(
        self,
        provider: str,
        response_sequence: list[str | Exception],
        model: str | None = None,
        tools: list[str | dict[str, Any]] | None = None,
        optional: bool = False,
        **response_kwargs: Any,
    ) -> None:
        """Mock LLM using HTTP interception (respx).

        The SDK's JSON parsing runs, so you get real SDK types
        (`Message`, `TextBlock`, `ChatCompletion`, etc.).

        Args:
            `provider`: LLM provider name.
            `response_sequence`: List of responses to return.
            `model`: Optional model name override.
            `tools`: Optional tool calls to include.
            `optional`: If `True`, won't raise `UnusedSimulationError` if unused.
            `**response_kwargs`: Additional provider-specific options.
        """
        # Filter out exceptions - HTTP mocking doesn't support inline exceptions yet
        text_responses = [r for r in response_sequence if isinstance(r, str)]

        if not text_responses:
            raise ValueError("HTTP mocking requires at least one string response")

        # Merge parameters
        if model is not None:
            response_kwargs["model"] = model

        if tools is not None:
            response_kwargs["tool_calls"] = ProviderSchemaFactory.create_tool_calls(provider, tools)

        # Enable HTTP mocking and register responses
        self._http_mocking_enabled = True
        self._simulation_tracker.register(provider, optional=optional)
        self._http_interceptor.mock_provider(provider, text_responses, **response_kwargs)

        # If context manager is already active, start HTTP interception immediately
        if self._active:
            self._http_interceptor.start()

    def _simulate_llm_setattr(
        self,
        target: str | Callable[..., Any] | None,
        provider: str,
        response_sequence: list[str | Exception],
        model: str | None = None,
        tools: list[str | dict[str, Any]] | None = None,
        **response_kwargs: Any,
    ) -> None:
        """Mock LLM using setattr patching (returns dicts, not SDK types)."""
        # Resolve target from provider if not provided
        if target is None:
            target = ProviderSchemaFactory.get_default_target(provider)
            if target is None:
                available = list(ProviderSchemaFactory._default_targets.keys())
                raise ValueError(
                    f"Provider '{provider}' has no default target.\n"
                    f"Available providers with defaults: {', '.join(available)}"
                )

        # Validate and resolve target
        if callable(target) and not isinstance(target, str):
            try:
                llm_path = _validate_and_resolve_target(target, "llm")
            except ValueError:
                qualname = getattr(target, "__qualname__", "")
                module = getattr(target, "__module__", "")
                if module and qualname:
                    llm_path = f"{module}.{qualname}"
                else:
                    llm_path = getattr(target, "__name__", str(target))
        else:
            llm_path = str(target)

        # Merge parameters
        if model is not None:
            response_kwargs["model"] = model

        if tools is not None:
            response_kwargs["tool_calls"] = ProviderSchemaFactory.create_tool_calls(provider, tools)

        # Track current response index for sequential responses
        response_index = {"current": 0}

        def _lifecycle_wrapper(*args: Any, **kwargs: Any) -> ProviderResponse:
            """Wrapper that triggers lifecycle events for LLM calls."""
            span = LLMCall(
                id=str(uuid.uuid7()),
                trace_id=str(uuid.uuid7()),
                start_time=time.time(),
                provider=provider,
                messages=kwargs.get("messages", []),
            )

            with self._lifecycle.start_span(span):
                idx = response_index["current"]
                response_index["current"] += 1

                response_content = get_response_by_index(response_sequence, idx, llm_path)

                response_dict = self._create_provider_response(
                    provider, response_content, **response_kwargs
                )

                span.response = response_content

                return response_dict

        rule = SimulationRule(side_effect=_lifecycle_wrapper)
        self._simulations[llm_path] = rule

        if self._active:
            self._apply_patch(llm_path)
