from __future__ import annotations

import functools
import logging
import warnings
from collections.abc import AsyncIterator, Mapping, Sequence
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Optional,
    TypeVar,
    Union,
)

from typing_extensions import Self, TypedDict

from langsmith import client as ls_client
from langsmith import run_helpers
from langsmith._internal._orjson import dumps as _dumps
from langsmith.schemas import InputTokenDetails, UsageMetadata

if TYPE_CHECKING:
    import httpx
    from anthropic import Anthropic, AsyncAnthropic
    from anthropic.lib.streaming import AsyncMessageStream, MessageStream
    from anthropic.types import Completion, Message, MessageStreamEvent

C = TypeVar("C", bound=Union["Anthropic", "AsyncAnthropic", Any])
logger = logging.getLogger(__name__)


@functools.lru_cache
def _get_not_given() -> Optional[tuple[type, ...]]:
    try:
        from anthropic._types import NotGiven, Omit

        return (NotGiven, Omit)
    except ImportError:
        return None


def _strip_not_given(d: dict) -> dict:
    try:
        if not_given := _get_not_given():
            d = {
                k: v
                for k, v in d.items()
                if not any(isinstance(v, t) for t in not_given)
            }
    except Exception as e:
        logger.error(f"Error stripping NotGiven: {e}")

    if "system" in d:
        d["messages"] = [{"role": "system", "content": d["system"]}] + d.get(
            "messages", []
        )
        d.pop("system")
    return {k: v for k, v in d.items() if v is not None}


def _infer_ls_params(prepopulated_invocation_params: dict, kwargs: dict):
    stripped = _strip_not_given(kwargs)

    stop = stripped.get("stop")
    if stop and isinstance(stop, str):
        stop = [stop]

    # Allowlist of safe invocation parameters to include
    # Only include known, non-sensitive parameters
    allowed_invocation_keys = {
        "mcp_servers",
        "service_tier",
        "tool_choice",
        "top_k",
        "top_p",
        "stream",
        "thinking",
    }

    # Only include allowlisted parameters
    invocation_params = {
        k: v for k, v in stripped.items() if k in allowed_invocation_keys
    }

    return {
        "ls_provider": "anthropic",
        "ls_model_type": "chat",
        "ls_model_name": stripped.get("model", None),
        "ls_temperature": stripped.get("temperature", None),
        "ls_max_tokens": stripped.get("max_tokens", None),
        "ls_stop": stop,
        "ls_invocation_params": {
            **prepopulated_invocation_params,
            **invocation_params,
        },
    }


@functools.lru_cache
def _get_sdk_accumulate_event() -> Optional[Callable]:
    try:
        from anthropic.lib.streaming._messages import accumulate_event

        return accumulate_event
    except ImportError:
        return None


def _create_usage_metadata(anthropic_token_usage: dict) -> UsageMetadata:
    input_tokens = anthropic_token_usage.get("input_tokens") or 0
    output_tokens = anthropic_token_usage.get("output_tokens") or 0

    input_token_details: dict = {}
    cache_read = anthropic_token_usage.get("cache_read_input_tokens") or 0
    if cache_read:
        input_token_details["cache_read"] = cache_read

    cache_creation_obj = anthropic_token_usage.get("cache_creation") or {}
    if cache_creation_obj:
        ephemeral_5m = cache_creation_obj.get("ephemeral_5m_input_tokens") or 0
        ephemeral_1h = cache_creation_obj.get("ephemeral_1h_input_tokens") or 0
        if ephemeral_5m:
            input_token_details["ephemeral_5m_input_tokens"] = ephemeral_5m
        if ephemeral_1h:
            input_token_details["ephemeral_1h_input_tokens"] = ephemeral_1h
    else:
        cache_creation = anthropic_token_usage.get("cache_creation_input_tokens") or 0
        if cache_creation:
            input_token_details["cache_creation"] = cache_creation

    # Anthropic cache tokens are ADDITIVE (not subsets of input_tokens like OpenAI).
    # Sum them into input_tokens so the backend cost calculation is correct.
    cache_token_sum = sum(input_token_details.values())
    adjusted_input = input_tokens + cache_token_sum
    adjusted_total = adjusted_input + output_tokens

    result = UsageMetadata(
        input_tokens=adjusted_input,
        output_tokens=output_tokens,
        total_tokens=adjusted_total,
    )
    if input_token_details:
        result["input_token_details"] = InputTokenDetails(**input_token_details)
    return result


def _message_to_outputs(message: Any) -> dict:
    """Convert an Anthropic Message to a flat outputs dict with usage_metadata."""
    # ParsedBetaMessage/ParsedMessage (from beta.messages.parse()) carry user-defined
    # Pydantic models in parsed_output and ParsedBetaTextBlock in content. These trigger
    # PydanticSerializationUnexpectedValue warnings because the values do not match the
    # declared union types in the base BetaMessage schema. Suppress for parsed types.
    if hasattr(message, "parsed_output"):
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            outputs = message.model_dump()
    else:
        outputs = message.model_dump()
    anthropic_token_usage = outputs.pop("usage", None)
    if anthropic_token_usage:
        outputs["usage_metadata"] = _create_usage_metadata(anthropic_token_usage)
    outputs.pop("type", None)

    content = outputs.get("content") or []
    tool_use_blocks = [
        b for b in content if isinstance(b, dict) and b.get("type") == "tool_use"
    ]
    if tool_use_blocks:
        text_parts = [
            b.get("text", "")
            for b in content
            if isinstance(b, dict) and b.get("type") == "text"
        ]
        outputs["content"] = "".join(text_parts) or None
        outputs["tool_calls"] = [
            {
                "id": block.get("id", f"call_{i}"),
                "type": "function",
                "index": i,
                "function": {
                    "name": block.get("name", ""),
                    "arguments": _dumps(block.get("input", {})).decode(),
                },
            }
            for i, block in enumerate(tool_use_blocks)
        ]
    return outputs


def _reduce_chat_chunks(all_chunks: Sequence) -> dict:
    accumulate = _get_sdk_accumulate_event()
    if accumulate is None:
        return {"output": all_chunks}
    full_message = None
    for chunk in all_chunks:
        try:
            full_message = accumulate(
                event=chunk,
                current_snapshot=full_message,
            )
        except RuntimeError as e:
            logger.debug(f"Error accumulating event in Anthropic Wrapper: {e}")
            return {"output": all_chunks}
    if full_message is None:
        return {"output": all_chunks}
    return _message_to_outputs(full_message)


def _reduce_completions(all_chunks: list[Completion]) -> dict:
    all_content = []
    for chunk in all_chunks:
        content = chunk.completion
        if content is not None:
            all_content.append(content)
    content = "".join(all_content)
    if all_chunks:
        d = all_chunks[-1].model_dump()
        d["choices"] = [{"text": content}]
    else:
        d = {"choices": [{"text": content}]}

    return d


def _process_chat_completion(outputs: Any):
    try:
        # Check if outputs is a LegacyAPIResponse wrapper (from with_raw_response).
        # The Anthropic SDK's LegacyAPIResponse wraps the actual response object.
        # Call .parse() to extract the Message for tracing.
        # See: anthropics/anthropic-sdk-python _legacy_response.py#L102
        if hasattr(outputs, "parse") and callable(outputs.parse):
            try:
                outputs = outputs.parse()
            except Exception:
                pass
        return _message_to_outputs(outputs)
    except BaseException as e:
        logger.debug(f"Error processing chat completion: {e}")
        return {"output": outputs}


def _get_wrapper(
    original_create: Callable,
    name: str,
    reduce_fn: Callable,
    prepopulated_invocation_params: dict,
    tracing_extra: TracingExtra,
) -> Callable:
    @functools.wraps(original_create)
    def create(*args, **kwargs):
        stream = kwargs.get("stream")
        decorator = run_helpers.traceable(
            name=name,
            run_type="llm",
            reduce_fn=reduce_fn if stream else None,
            process_inputs=_strip_not_given,
            process_outputs=_process_chat_completion,
            _invocation_params_fn=functools.partial(
                _infer_ls_params, prepopulated_invocation_params
            ),
            **tracing_extra,
        )

        result = decorator(original_create)(*args, **kwargs)
        return result

    @functools.wraps(original_create)
    async def acreate(*args, **kwargs):
        stream = kwargs.get("stream")
        decorator = run_helpers.traceable(
            name=name,
            run_type="llm",
            reduce_fn=reduce_fn if stream else None,
            process_inputs=_strip_not_given,
            process_outputs=_process_chat_completion,
            _invocation_params_fn=functools.partial(
                _infer_ls_params, prepopulated_invocation_params
            ),
            **tracing_extra,
        )
        result = await decorator(original_create)(*args, **kwargs)
        return result

    return acreate if run_helpers.is_async(original_create) else create


def _get_stream_wrapper(
    original_stream: Callable,
    name: str,
    prepopulated_invocation_params: dict,
    tracing_extra: TracingExtra,
) -> Callable:
    """Create a wrapper for Anthropic's streaming context manager."""
    is_async = "async" in str(original_stream).lower()
    configured_traceable = run_helpers.traceable(
        name=name,
        reduce_fn=_reduce_chat_chunks,
        run_type="llm",
        process_inputs=_strip_not_given,
        _invocation_params_fn=functools.partial(
            _infer_ls_params, prepopulated_invocation_params
        ),
        **tracing_extra,
    )
    configured_traceable_text = run_helpers.traceable(
        name=name,
        run_type="llm",
        process_inputs=_strip_not_given,
        process_outputs=_process_chat_completion,
        _invocation_params_fn=functools.partial(
            _infer_ls_params, prepopulated_invocation_params
        ),
        **tracing_extra,
    )

    if is_async:

        class AsyncMessageStreamWrapper:
            def __init__(
                self,
                wrapped: AsyncMessageStream,
                **kwargs,
            ) -> None:
                self._wrapped = wrapped
                self._kwargs = kwargs

            @property
            def text_stream(self):
                @configured_traceable_text
                async def _text_stream(**_):
                    async for chunk in self._wrapped.text_stream:
                        yield chunk
                    run_tree = run_helpers.get_current_run_tree()
                    final_message = await self._wrapped.get_final_message()
                    outputs = _message_to_outputs(final_message)
                    run_tree.outputs = outputs
                    if usage := outputs.get("usage_metadata"):
                        run_tree.metadata["usage_metadata"] = usage

                return _text_stream(**self._kwargs)

            @property
            def response(self) -> httpx.Response:
                return self._wrapped.response

            @property
            def request_id(self) -> str | None:
                return self._wrapped.request_id

            async def __anext__(self) -> MessageStreamEvent:
                aiter = self.__aiter__()
                return await aiter.__anext__()

            async def __aiter__(self) -> AsyncIterator[MessageStreamEvent]:
                @configured_traceable
                def traced_iter(**_):
                    return self._wrapped.__aiter__()

                async for chunk in traced_iter(**self._kwargs):
                    yield chunk

            async def __aenter__(self) -> Self:
                await self._wrapped.__aenter__()
                return self

            async def __aexit__(self, *exc) -> None:
                await self._wrapped.__aexit__(*exc)

            async def close(self) -> None:
                await self._wrapped.close()

            async def get_final_message(self) -> Message:
                return await self._wrapped.get_final_message()

            async def get_final_text(self) -> str:
                return await self._wrapped.get_final_text()

            async def until_done(self) -> None:
                await self._wrapped.until_done()

            @property
            def current_message_snapshot(self) -> Message:
                return self._wrapped.current_message_snapshot

        class AsyncMessagesStreamManagerWrapper:
            def __init__(self, **kwargs):
                self._kwargs = kwargs

            async def __aenter__(self):
                self._manager = original_stream(**self._kwargs)
                stream = await self._manager.__aenter__()
                return AsyncMessageStreamWrapper(stream, **self._kwargs)

            async def __aexit__(self, *exc):
                await self._manager.__aexit__(*exc)

        return AsyncMessagesStreamManagerWrapper
    else:

        class MessageStreamWrapper:
            def __init__(
                self,
                wrapped: MessageStream,
                **kwargs,
            ) -> None:
                self._wrapped = wrapped
                self._kwargs = kwargs

            @property
            def response(self) -> Any:
                return self._wrapped.response

            @property
            def request_id(self) -> str | None:
                return self._wrapped.request_id  # type: ignore[no-any-return]

            @property
            def text_stream(self):
                @configured_traceable_text
                def _text_stream(**_):
                    yield from self._wrapped.text_stream
                    run_tree = run_helpers.get_current_run_tree()
                    final_message = self._wrapped.get_final_message()
                    outputs = _message_to_outputs(final_message)
                    run_tree.outputs = outputs
                    if usage := outputs.get("usage_metadata"):
                        run_tree.metadata["usage_metadata"] = usage

                return _text_stream(**self._kwargs)

            def __next__(self) -> MessageStreamEvent:
                return self.__iter__().__next__()

            def __iter__(self):
                @configured_traceable
                def traced_iter(**_):
                    return self._wrapped.__iter__()

                return traced_iter(**self._kwargs)

            def __enter__(self) -> Self:
                self._wrapped.__enter__()
                return self

            def __exit__(self, *exc) -> None:
                self._wrapped.__exit__(*exc)

            def close(self) -> None:
                self._wrapped.close()

            def get_final_message(self) -> Message:
                return self._wrapped.get_final_message()

            def get_final_text(self) -> str:
                return self._wrapped.get_final_text()

            def until_done(self) -> None:
                return self._wrapped.until_done()

            @property
            def current_message_snapshot(self) -> Message:
                return self._wrapped.current_message_snapshot

        class MessagesStreamManagerWrapper:
            def __init__(self, **kwargs):
                self._kwargs = kwargs

            def __enter__(self):
                self._manager = original_stream(**self._kwargs)
                return MessageStreamWrapper(self._manager.__enter__(), **self._kwargs)

            def __exit__(self, *exc):
                self._manager.__exit__(*exc)

        return MessagesStreamManagerWrapper


class TracingExtra(TypedDict, total=False):
    metadata: Optional[Mapping[str, Any]]
    tags: Optional[list[str]]
    client: Optional[ls_client.Client]


def wrap_anthropic(
    client: C,
    *,
    tracing_extra: Optional[TracingExtra] = None,
    chat_name: str = "ChatAnthropic",
    completions_name: str = "Anthropic",
) -> C:
    """Patch the Anthropic client to make it traceable.

    Args:
        client: The client to patch.
        tracing_extra: Extra tracing information.
        chat_name: The run name for the messages endpoint.
        completions_name: The run name for the completions endpoint.

    Returns:
        The patched client.

    Example:
        ```python
        import anthropic
        from langsmith import wrappers

        client = wrappers.wrap_anthropic(anthropic.Anthropic())

        # Use Anthropic client same as you normally would:
        system = "You are a helpful assistant."
        messages = [
            {
                "role": "user",
                "content": "What physics breakthroughs do you predict will happen by 2300?",
            }
        ]
        completion = client.messages.create(
            model="claude-3-5-sonnet-latest",
            messages=messages,
            max_tokens=1000,
            system=system,
        )
        print(completion.content)

        # With raw response to access headers:
        raw_response = client.messages.with_raw_response.create(
            model="claude-3-5-sonnet-latest",
            messages=messages,
            max_tokens=1000,
            system=system,
        )
        print(raw_response.headers)  # Access HTTP headers
        message = raw_response.parse()  # Get parsed response

        # You can also use the streaming context manager:
        with client.messages.stream(
            model="claude-3-5-sonnet-latest",
            messages=messages,
            max_tokens=1000,
            system=system,
        ) as stream:
            for text in stream.text_stream:
                print(text, end="", flush=True)
            message = stream.get_final_message()
        ```
    """  # noqa: E501
    tracing_extra = tracing_extra or {}

    # Extract ls_invocation_params from metadata
    metadata = dict(tracing_extra.get("metadata") or {})
    prepopulated_invocation_params = metadata.pop("ls_invocation_params", {})

    # Create new tracing_extra without ls_invocation_params in metadata
    tracing_extra_rest: TracingExtra = {  # type: ignore[assignment]
        k: v for k, v in tracing_extra.items() if k != "metadata"
    }
    if metadata:
        tracing_extra_rest["metadata"] = metadata  # type: ignore[typeddict-item]

    client.messages.create = _get_wrapper(  # type: ignore[method-assign]
        client.messages.create,
        chat_name,
        _reduce_chat_chunks,
        prepopulated_invocation_params,
        tracing_extra_rest,
    )

    client.messages.stream = _get_stream_wrapper(  # type: ignore[method-assign]
        client.messages.stream,
        chat_name,
        prepopulated_invocation_params,
        tracing_extra_rest,
    )
    client.completions.create = _get_wrapper(  # type: ignore[method-assign]
        client.completions.create,
        completions_name,
        _reduce_completions,
        prepopulated_invocation_params,
        tracing_extra_rest,
    )

    if (
        hasattr(client, "beta")
        and hasattr(client.beta, "messages")
        and hasattr(client.beta.messages, "create")
    ):
        client.beta.messages.create = _get_wrapper(  # type: ignore[method-assign]
            client.beta.messages.create,  # type: ignore
            chat_name,
            _reduce_chat_chunks,
            prepopulated_invocation_params,
            tracing_extra_rest,
        )

    if (
        hasattr(client, "beta")
        and hasattr(client.beta, "messages")
        and hasattr(client.beta.messages, "parse")
    ):
        client.beta.messages.parse = _get_wrapper(  # type: ignore[method-assign]
            client.beta.messages.parse,  # type: ignore
            chat_name,
            _reduce_chat_chunks,
            prepopulated_invocation_params,
            tracing_extra_rest,
        )
    return client
