# Copyright (c) Microsoft. All rights reserved.

from __future__ import annotations

import functools
import logging
import random
from collections.abc import Coroutine
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Concatenate,
    Iterator,
    List,
    Literal,
    Optional,
    ParamSpec,
    Sequence,
    TypeVar,
    overload,
)

from agentlightning.types import Dataset

if TYPE_CHECKING:
    from agentlightning.llm_proxy import LLMProxy
    from agentlightning.store.base import LightningStore

    from .base import Algorithm

T_task = TypeVar("T_task")
T_algo = TypeVar("T_algo", bound="Algorithm")

P = ParamSpec("P")
R = TypeVar("R")


logger = logging.getLogger(__name__)


def batch_iter_over_dataset(dataset: Dataset[T_task], batch_size: int) -> Iterator[Sequence[T_task]]:
    """
    Create an infinite iterator that yields batches from the dataset.

    When batch_size >= dataset size, yields the entire shuffled dataset repeatedly.
    When batch_size < dataset size, yields batches of the specified size, reshuffling
    after each complete pass through the dataset.

    Args:
        dataset: The dataset to iterate over.
        batch_size: The desired batch size.

    Yields:
        Sequences of tasks from the dataset. Each task appears at most once per epoch.
    """
    if batch_size >= len(dataset):
        while True:
            dataset_copy = [dataset[i] for i in range(len(dataset))]
            random.shuffle(dataset_copy)
            yield dataset_copy

    else:
        current_batch: List[int] = []
        while True:
            indices = list(range(len(dataset)))
            random.shuffle(indices)
            for index in indices:
                if index in current_batch:
                    continue
                current_batch.append(index)
                if len(current_batch) == batch_size:
                    yield [dataset[index] for index in current_batch]
                    current_batch = []


def with_store(
    func: Callable[Concatenate[T_algo, LightningStore, P], Coroutine[Any, Any, R]],
) -> Callable[Concatenate[T_algo, P], Coroutine[Any, Any, R]]:
    """Inject the algorithm's `LightningStore` into coroutine methods.

    The decorator calls `Algorithm.get_store()` once per invocation and passes the
    resulting store as an explicit argument to the wrapped coroutine. Decorated
    methods therefore receive the resolved store even when invoked by helper
    utilities rather than directly by the algorithm.

    Args:
        func: The coroutine that expects `(self, store, *args, **kwargs)`.

    Returns:
        A coroutine wrapper that automatically retrieves the store and forwards it
        to `func`.
    """

    @functools.wraps(func)
    async def wrapper(self: T_algo, *args: P.args, **kwargs: P.kwargs) -> R:
        store = self.get_store()
        return await func(self, store, *args, **kwargs)

    return wrapper


@overload
def with_llm_proxy(
    required: Literal[False] = False,
    auto_start: bool = True,
) -> Callable[
    [Callable[Concatenate[T_algo, Optional[LLMProxy], P], Coroutine[Any, Any, R]]],
    Callable[Concatenate[T_algo, P], Coroutine[Any, Any, R]],
]: ...


@overload
def with_llm_proxy(
    required: Literal[True],
    auto_start: bool = True,
) -> Callable[
    [Callable[Concatenate[T_algo, LLMProxy, P], Coroutine[Any, Any, R]]],
    Callable[Concatenate[T_algo, P], Coroutine[Any, Any, R]],
]: ...


def with_llm_proxy(
    required: bool = False,
    auto_start: bool = True,
) -> Callable[
    [Callable[..., Coroutine[Any, Any, Any]]],
    Callable[..., Coroutine[Any, Any, Any]],
]:
    """Resolve and optionally lifecycle-manage the configured LLM proxy.

    Args:
        required: When True, raises `ValueError` if the algorithm does not have an
            [`LLMProxy`][agentlightning.LLMProxy] set. When False, the wrapped coroutine receives
            `None` if no proxy is available.
        auto_start: When True, [`LLMProxy.start()`][agentlightning.LLMProxy.start] is invoked if the proxy is not
            already running before calling `func` and [`LLMProxy.stop()`][agentlightning.LLMProxy.stop] is
            called afterwards.

    Returns:
        A decorator that injects the [`LLMProxy`][agentlightning.LLMProxy] (or `None`) as the first
        argument after `self` and manages automatic startup/shutdown when requested.
    """

    def decorator(
        func: Callable[..., Coroutine[Any, Any, Any]],
    ) -> Callable[..., Coroutine[Any, Any, Any]]:
        @functools.wraps(func)
        async def wrapper(self: Algorithm, *args: Any, **kwargs: Any) -> Any:
            llm_proxy = self.get_llm_proxy()

            if required and llm_proxy is None:
                raise ValueError(
                    "LLM proxy is required but not configured. Call set_llm_proxy() before using this method."
                )

            auto_started = False
            if auto_start and llm_proxy is not None:
                if llm_proxy.is_running():
                    logger.info("Proxy is already running, skipping start")
                else:
                    logger.info("Starting proxy, managed by the algorithm")
                    await llm_proxy.start()
                    auto_started = True

            try:
                # At type level, overloads guarantee that if `required=True`
                # then `func` expects a non-optional LLMProxy.
                return await func(self, llm_proxy, *args, **kwargs)
            finally:
                if auto_started and llm_proxy is not None:
                    logger.info("Stopping proxy, managed by the algorithm")
                    await llm_proxy.stop()

        return wrapper

    return decorator
