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

"""Simulation validation tracking.

Validates that simulations are properly used during tests:
1. Provider-level: registered simulations must be triggered
2. Span-level: @link_llm decorated functions must make actual HTTP calls
"""

from __future__ import annotations

from tenro.construct.http.builders import ProviderSchemaFactory
from tenro.core.spans import LLMCall
from tenro.errors import MissingLLMCallError, UnusedSimulationError


class SimulationTracker:
    """Validates simulation registration, triggering, and execution.

    Tracks two levels of validation:
    1. Provider-level: Did registered simulations get triggered?
    2. Span-level: Did @link_llm calls actually make HTTP requests?

    Raises `MissingLLMCallError` when `@link_llm` ran but no HTTP call happened.
    Raises `UnusedSimulationError` when simulations were registered but never used.

    Supports optional simulations that won't error if unused (for branch
    coverage, optional paths, and fallback tests).
    """

    def __init__(self) -> None:
        # Provider-level tracking
        self._registered: set[str] = set()
        self._optional: set[str] = set()
        self._triggered: set[str] = set()
        # Span-level tracking (which LLMCall spans made HTTP)
        self._http_span_ids: set[str] = set()

    def register(self, provider: str, *, optional: bool = False) -> None:
        """Record that a simulation was registered for a provider.

        Args:
            `provider`: Provider name (e.g., "openai", "anthropic").
            `optional`: If `True`, this simulation won't error if unused.
        """
        self._registered.add(provider)
        if optional:
            self._optional.add(provider)

    def mark_triggered(self, provider: str) -> None:
        """Record that a simulation was actually triggered."""
        self._triggered.add(provider)

    def mark_llm_http_call(self, span_id: str) -> None:
        """Record that an LLMCall span made an HTTP call.

        Args:
            `span_id`: LLMCall span ID that triggered HTTP.
        """
        self._http_span_ids.add(span_id)

    def did_llm_make_http_call(self, span_id: str) -> bool:
        """Check whether an LLMCall span made an HTTP call.

        Args:
            `span_id`: LLMCall span ID to check.
        Returns:
            `True` if the span triggered HTTP.
        """
        return span_id in self._http_span_ids

    def validate(
        self,
        llm_calls: list[LLMCall],
        supported_providers: list[str],
    ) -> None:
        """Validate simulation usage and raise errors if invalid.

        Args:
            `llm_calls`: LLM calls recorded via `@link_llm` decorator.
            `supported_providers`: Providers that support HTTP mocking.
        Raises:
            MissingLLMCallError: `@link_llm` ran but no HTTP call was made.
            UnusedSimulationError: Simulation registered but never triggered.
        """
        self._check_unused_providers(llm_calls, supported_providers)
        self._check_missing_http_calls(llm_calls, supported_providers)

    def _check_unused_providers(
        self,
        llm_calls: list[LLMCall],
        supported_providers: list[str],
    ) -> None:
        """Check for completely unused providers."""
        unused = self._registered - self._triggered - self._optional
        if not unused:
            return

        provider = sorted(unused)[0]
        decorator_calls = [
            c for c in llm_calls if c.provider == provider and c.caller_signature is not None
        ]

        if decorator_calls:
            msg = self._build_missing_call_message(
                provider=provider,
                decorator_calls=decorator_calls,
                supported_providers=supported_providers,
            )
            raise MissingLLMCallError(msg)

        msg = self._build_unused_simulation_message(
            provider=provider,
            supported_providers=supported_providers,
        )
        raise UnusedSimulationError(msg)

    def _check_missing_http_calls(
        self,
        llm_calls: list[LLMCall],
        supported_providers: list[str],
    ) -> None:
        """Check for @link_llm calls that didn't make HTTP requests."""
        for provider in self._registered - self._optional:
            decorator_spans = [
                c for c in llm_calls if c.provider == provider and c.caller_signature is not None
            ]
            failed_calls = [c for c in decorator_spans if not self.did_llm_make_http_call(c.id)]
            if failed_calls:
                msg = self._build_partial_failure_message(
                    provider=provider,
                    failed_calls=failed_calls,
                    total_calls=len(decorator_spans),
                    supported_providers=supported_providers,
                )
                raise MissingLLMCallError(msg)

    def _build_missing_call_message(
        self,
        provider: str,
        decorator_calls: list[LLMCall],
        supported_providers: list[str],
    ) -> str:
        """Build an error message for missing provider calls."""
        lines = [
            f"@link_llm('{provider}') was called but no LLM HTTP request was made to intercept",
            "",
            "Your decorated function returned without hitting the LLM provider API.",
            "This breaks the harness because the simulation never had a chance to activate.",
            "",
            f"  Provider: {provider}",
            f"  @link_llm calls observed: {len(decorator_calls)}",
            "  LLM Provider HTTP calls intercepted: 0",
        ]

        if decorator_calls:
            lines.append("")
            lines.append("Functions that didn't make HTTP calls:")
            seen: set[str] = set()
            for call in decorator_calls:
                if call.caller_signature and call.caller_signature not in seen:
                    seen.add(call.caller_signature)
                    loc = f" at {call.caller_location}" if call.caller_location else ""
                    lines.append(f"  - {call.caller_signature}{loc}")

        lines.extend(
            [
                "",
                "This usually means your @link_llm function returned a stub",
                "instead of calling the real LLM client.",
                "",
                f"Supported HTTP providers: {', '.join(sorted(supported_providers))}",
                "Note: HTTP interception only works with httpx-based clients "
                "(e.g., openai, anthropic, google-genai).",
            ]
        )

        example = ProviderSchemaFactory.get_code_example(provider)
        if example:
            lines.extend(
                [
                    "",
                    "Fix: Ensure your decorated function makes real HTTP calls:",
                    f"  @link_llm('{provider}')",
                    "  def call_llm(prompt: str) -> str:",
                    f"      {example['client']}",
                    f"      {example['call']}",
                    f"      {example['extract']}",
                ]
            )
        else:
            lines.extend(
                [
                    "",
                    "Fix: Ensure your decorated function makes real HTTP calls",
                    f"to the {provider} API.",
                ]
            )

        return "\n".join(lines)

    def _build_partial_failure_message(
        self,
        provider: str,
        failed_calls: list[LLMCall],
        total_calls: int,
        supported_providers: list[str],
    ) -> str:
        """Build error message for partial failure."""
        http_calls = total_calls - len(failed_calls)
        lines = [
            f"Some @link_llm('{provider}') calls didn't make HTTP requests",
            "",
            f"  @link_llm calls: {total_calls}",
            f"  HTTP calls made: {http_calls}",
            f"  Calls missing HTTP: {len(failed_calls)}",
            "",
            "Functions that didn't make HTTP calls:",
        ]

        seen: set[str] = set()
        for call in failed_calls:
            if call.caller_signature and call.caller_signature not in seen:
                seen.add(call.caller_signature)
                loc = f" at {call.caller_location}" if call.caller_location else ""
                lines.append(f"  - {call.caller_signature}{loc}")

        lines.extend(
            [
                "",
                "These functions returned without hitting the provider API.",
                "Check that they call the real LLM client, not return stubs.",
                "",
                "Note: HTTP interception only works with httpx-based clients "
                "(e.g., openai, anthropic, google-genai).",
            ]
        )

        return "\n".join(lines)

    def _build_unused_simulation_message(
        self,
        provider: str,
        supported_providers: list[str],
    ) -> str:
        """Build a detailed error message for unused simulation."""
        lines = [
            f"HTTP mock for '{provider}' was never triggered",
            "",
            "Your test configured simulate_llm() for this provider,",
            "but no LLM calls were recorded for it.",
            "",
            f"  Provider: {provider}",
            "  @link_llm calls recorded: 0",
            "  HTTP calls intercepted: 0",
            "",
            "This usually means the code path that would call the LLM",
            "never executed during the test.",
            "",
            f"Supported LLM providers: {', '.join(sorted(supported_providers))}",
            "",
            "Fix: Remove the unused simulation or execute the code path:",
            f"  construct.simulate_llm(provider='{provider}', response='...')",
            "  # ... trigger the LLM call ...",
        ]
        return "\n".join(lines)

    def reset(self) -> None:
        """Clear all tracking state."""
        self._registered.clear()
        self._optional.clear()
        self._triggered.clear()
        self._http_span_ids.clear()
