import asyncio
import contextvars
import copy
import logging
import threading
from contextlib import contextmanager
from typing import Any

import cloudpickle

from dspy.dsp.utils.utils import dotdict

logger = logging.getLogger(__name__)

DEFAULT_CONFIG = dotdict(
    lm=None,
    adapter=None,
    rm=None,
    branch_idx=0,
    trace=[],
    callbacks=[],
    async_max_workers=8,
    send_stream=None,
    disable_history=False,
    track_usage=False,
    usage_tracker=None,
    caller_predict=None,
    caller_modules=None,
    stream_listeners=[],
    provide_traceback=False,  # Whether to include traceback information in error logs.
    num_threads=8,  # Number of threads to use for parallel processing.
    max_errors=10,  # Maximum errors before halting operations.
    # If true, async tools can be called in sync mode by getting converted to sync.
    allow_tool_async_sync_conversion=False,
    max_history_size=10000,
    max_trace_size=10000,
    warn_on_type_mismatch=True,  # Whether to log warnings when a module's input type doesn't match the signature type.
)

# Global base configuration and owner tracking
main_thread_config = copy.deepcopy(DEFAULT_CONFIG)
config_owner_thread_id = None
config_owner_async_task = None

# Global lock for settings configuration
global_lock = threading.Lock()

thread_local_overrides = contextvars.ContextVar("context_overrides", default=dotdict())


class Settings:
    """
    A singleton class for DSPy configuration settings.
    Thread-safe global configuration.
    - 'configure' can be called by only one 'owner' thread (the first thread that calls it).
    - Other threads see the configured global values from 'main_thread_config'.
    - 'context' sets thread-local overrides. These overrides propagate to threads spawned
      inside that context block, when (and only when!) using a ParallelExecutor that copies overrides.

      1. Only one unique thread (which can be any thread!) can call dspy.configure.
      2. It affects a global state, visible to all. As a result, user threads work, but they shouldn't be
         mixed with concurrent changes to dspy.configure from the "main" thread.
         (TODO: In the future, add warnings: if there are near-in-time user-thread reads followed by .configure calls.)
      3. Any thread can use dspy.context. It propagates to child threads created with DSPy primitives: Parallel, asyncify, etc.
    """

    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    @property
    def lock(self):
        return global_lock

    def __getattr__(self, name):
        overrides = thread_local_overrides.get()
        if name in overrides:
            return overrides[name]
        elif name in main_thread_config:
            return main_thread_config[name]
        else:
            raise AttributeError(f"'Settings' object has no attribute '{name}'")

    def __setattr__(self, name, value):
        if name in ("_instance",):
            super().__setattr__(name, value)
        else:
            self.configure(**{name: value})

    def __getitem__(self, key):
        return self.__getattr__(key)

    def __setitem__(self, key, value):
        self.__setattr__(key, value)

    def __contains__(self, key):
        overrides = thread_local_overrides.get()
        return key in overrides or key in main_thread_config

    def get(self, key, default=None):
        try:
            return self[key]
        except AttributeError:
            return default

    def copy(self):
        overrides = thread_local_overrides.get()
        return dotdict({**main_thread_config, **overrides})

    @property
    def config(self):
        return self.copy()

    def _ensure_configure_allowed(self):
        global main_thread_config, config_owner_thread_id, config_owner_async_task
        current_thread_id = threading.get_ident()

        if config_owner_thread_id is None:
            # First `configure` call assigns the owner thread id.
            config_owner_thread_id = current_thread_id

        if config_owner_thread_id != current_thread_id:
            # Disallow a second `configure` calls from other threads.
            raise RuntimeError("dspy.settings can only be changed by the thread that initially configured it.")

        # Async task doesn't allow a second `configure` call, must use dspy.context(...) instead.
        is_async_task = False
        try:
            if asyncio.current_task() is not None:
                is_async_task = True
        except RuntimeError:
            # This exception (e.g., "no current task") means we are not in an async loop/task,
            # or asyncio module itself is not fully functional in this specific sub-thread context.
            is_async_task = False

        if not is_async_task:
            return

        if config_owner_async_task is None:
            # First `configure` call assigns the owner async task.
            config_owner_async_task = asyncio.current_task()
            return

        # We are in an async task. Now check for IPython and allow calling `configure` from IPython.
        in_ipython = False
        try:
            from IPython import get_ipython

            # get_ipython is a global injected by IPython environments.
            # We check its existence and type to be more robust.
            in_ipython = get_ipython() is not None
        except Exception:
            # If `IPython` is not installed or `get_ipython` failed, we are not in an IPython environment.
            in_ipython = False

        if not in_ipython and config_owner_async_task != asyncio.current_task():
            raise RuntimeError(
                "dspy.configure(...) can only be called from the same async task that called it first. Please "
                "use `dspy.context(...)` in other async tasks instead."
            )

    def configure(self, **kwargs):
        """Set the default language model, adapter, and other settings for DSPy.

        Call `dspy.configure(...)` once near the top of your script. Every
        DSPy module will use these defaults unless you override them with
        `dspy.context(...)`. The values persist until you call
        `dspy.configure(...)` again.

        Only the thread that first calls `dspy.configure(...)` may call it
        again. Use `dspy.context(...)` for temporary overrides in other
        threads or async tasks.

        Args:
            **kwargs: Settings to update. Common keys include `lm` (a
                `dspy.LM`), `adapter` (e.g. `dspy.JSONAdapter()`),
                `callbacks`, `track_usage`, `async_max_workers`, and
                `num_threads`.

        Examples:
            Set a default LM:
            ```python
            import dspy

            dspy.configure(lm=dspy.LM("openai/gpt-5-mini"))
            ```

            Set multiple defaults at once:
            ```python
            import dspy

            dspy.configure(
                lm=dspy.LM("anthropic/claude-sonnet-4-6"),
                adapter=dspy.JSONAdapter(),
                track_usage=True,
            )
            ```

        See Also:
            [`dspy.LM`][dspy.LM]: create the language model you pass as `lm`.
            `dspy.context`: temporary overrides inside one block.
        """
        # `dspy.configure` is documented manually in docs/docs/api/utils/context.md
        # changes here should be reflected there as well.
        # If no exception is raised, the `configure` call is allowed.
        self._ensure_configure_allowed()

        # Update global config
        for k, v in kwargs.items():
            main_thread_config[k] = v

    @contextmanager
    def context(self, **kwargs):
        """Override DSPy settings for one `with` block.

        Use `dspy.context(...)` when you need temporary settings—a different
        LM, adapter, or flag—without changing the process-wide defaults from
        `dspy.configure(...)`. The block inherits every current setting,
        overrides only the keys you pass, and restores the originals on exit.

        Unlike `dspy.configure(...)`, you can call `dspy.context(...)` from
        any thread or async task.

        Args:
            **kwargs: Settings to override, such as `lm`, `adapter`,
                `track_usage`, or `allow_tool_async_sync_conversion`.

        Examples:
            Use a different LM for one call:
            ```python
            import dspy

            dspy.configure(lm=dspy.LM("openai/gpt-5-mini"))
            qa = dspy.Predict("question -> answer")

            with dspy.context(lm=dspy.LM("anthropic/claude-sonnet-4-6")):
                result = qa(question="What is the capital of France?")
                # uses claude-sonnet-4-6 inside this block
            # back to gpt-5-mini here
            ```

        See Also:
            `dspy.configure`: set process-wide defaults.
        """
        # `dspy.context` is documented manually in docs/docs/api/utils/context.md
        # changes here should be reflected there as well.
        original_overrides = thread_local_overrides.get().copy()
        new_overrides = dotdict({**main_thread_config, **original_overrides, **kwargs})
        token = thread_local_overrides.set(new_overrides)

        try:
            yield
        finally:
            thread_local_overrides.reset(token)

    def __repr__(self):
        overrides = thread_local_overrides.get()
        combined_config = {**main_thread_config, **overrides}
        return repr(combined_config)

    def save(
        self, path: str,
        modules_to_serialize: list[str] | None = None,
        exclude_keys: list[str] | None = None,
    ):
        """
        Save the settings to a file using cloudpickle.

        Args:
            path: The file path to save the settings to.
            modules_to_serialize (list or None): A list of modules to serialize with cloudpickle's `register_pickle_by_value`.
                If None, then no modules will be registered for serialization.
            exclude_keys (list or None): A list of keys to exclude during saving.
        """
        logger.warning(
            "`dspy.settings` are serialized using cloudpickle. Because cloudpickle allows for the "
            "execution of arbitrary code during deserialization, you should only load files from "
            "verified sources within a trusted environment."
        )
        try:
            modules_to_serialize = modules_to_serialize or []
            for module in modules_to_serialize:
                cloudpickle.register_pickle_by_value(module)

            exclude_keys = exclude_keys or []
            data = {key: value for key, value in self.config.items() if key not in exclude_keys}
            with open(path, "wb") as f:
                cloudpickle.dump(data, f)
        except Exception as e:
            raise RuntimeError(
                f"Saving failed with error: {e}. Please remove the non-picklable attributes from the values "
                "in the `dspy.settings`."
            )

    @classmethod
    def load(cls, path: str, allow_pickle: bool = False) -> dict[str, Any]:
        """
        Load the settings from a file using cloudpickle.

        Args:
            path: The file path to load the settings from.
            allow_pickle: Whether to allow loading with pickle. Loading untrusted .pkl files
                can run arbitrary code. Set to True only if you trust the source of the file.

        Returns:
            A dict that stores the loaded settings.
        """
        if not allow_pickle:
            raise ValueError(
                "Loading .pkl files can run arbitrary code, which may be dangerous. "
                "Set `allow_pickle=True` if you trust the source of the file."
            )

        with open(path, "rb") as f:
            configs = cloudpickle.load(f)

        return configs


settings = Settings()
