import abc
import functools
from collections.abc import Callable
from multiprocessing import Lock
from typing import TypeVar

from pydantic import BaseModel

from benchmark.tau_bench.model_utils import func_tools
from benchmark.tau_bench.model_utils.api.exception import (
    APIError,
    execute_and_filter_model_errors,
)
from benchmark.tau_bench.model_utils.model.exception import ModelError

T = TypeVar("T")


class SamplingStrategy(abc.ABC):
    @abc.abstractmethod
    def execute(
        self, invocable_or_invokables: Callable[..., T] | list[Callable[..., T]]
    ) -> T:
        raise NotImplementedError


def catch_model_errors(func: Callable[..., T]) -> Callable[..., T]:
    @functools.wraps(func)
    def wrapper(*args: object, **kwargs: object) -> T:
        try:
            return func(*args, **kwargs)
        except ModelError as e:
            raise APIError(
                short_message=str(e),
                report={
                    "prompt": e.prompt,
                    "response": e.response,
                    "error_message": str(e),
                },
            ) from e

    return wrapper


class SingleSamplingStrategy(SamplingStrategy):
    @catch_model_errors
    def execute(self, invocable_or_invokables: Callable[..., T]) -> T:
        assert isinstance(invocable_or_invokables, Callable)
        return invocable_or_invokables()


class RedundantSamplingStrategy(SamplingStrategy):
    def __init__(self, n: int = 2) -> None:
        assert n > 0
        self.n: int = n

    @catch_model_errors
    def execute(
        self, invocable_or_invokables: Callable[..., T] | list[Callable[..., T]]
    ) -> T:
        results: list[T] = execute_and_filter_model_errors(
            [lambda: invocable_or_invokables() for _ in range(self.n)]
            if isinstance(invocable_or_invokables, Callable)
            else invocable_or_invokables
        )
        assert len(results) > 0
        return results[0]


class RetrySamplingStrategy(SamplingStrategy):
    def __init__(self, max_retries: int = 5) -> None:
        assert max_retries > 0
        self.max_retries: int = max_retries

    @catch_model_errors
    def execute(self, invocable_or_invokables: Callable[..., T]) -> T:
        assert isinstance(invocable_or_invokables, Callable)
        first_error: ModelError | None = None
        for _ in range(self.max_retries):
            try:
                return invocable_or_invokables()
            except ModelError as e:
                if first_error is None:
                    first_error = e
        assert first_error is not None
        raise first_error


class MajoritySamplingStrategy(SamplingStrategy):
    def __init__(
        self,
        n: int = 5,
        max_concurrency: int | None = None,
        panic_on_first_model_error: bool = False,
    ) -> None:
        self.n: int = n
        self.max_concurrency: int = (
            max_concurrency if max_concurrency is not None else n
        )
        self.panic_on_first_model_error: bool = panic_on_first_model_error

    @catch_model_errors
    def execute(
        self, invocable_or_invokables: Callable[..., T] | list[Callable[..., T]]
    ) -> T:
        if self.panic_on_first_model_error:
            if isinstance(invocable_or_invokables, Callable):
                results: list[T] = list(
                    func_tools.map(
                        lambda _: invocable_or_invokables(),
                        range(self.n),
                        max_concurrency=self.max_concurrency,
                    )
                )
            else:
                results = list(
                    func_tools.map(
                        lambda invocable: invocable(),
                        invocable_or_invokables,
                        max_concurrency=self.max_concurrency,
                    )
                )
        else:
            results = execute_and_filter_model_errors(
                (
                    [lambda: invocable_or_invokables() for _ in range(self.n)]
                    if isinstance(invocable_or_invokables, Callable)
                    else invocable_or_invokables
                ),
                max_concurrency=self.max_concurrency,
            )
        if not self.panic_on_first_model_error and len(results) == 0:
            raise SamplingError(
                "No results from majority sampling (all calls resulted in LLM errors)"
            )
        return get_majority(results)


def get_majority(results: list[T]) -> T:
    grouped: dict[str, list[T]] = {}
    for result in results:
        if isinstance(result, BaseModel):
            key: str = result.model_dump_json()
        else:
            key = str(result)
        if key not in grouped:
            # for now, just store duplicate results for the count
            grouped[key] = [result]
        else:
            grouped[key].append(result)
    majority: str = max(grouped, key=lambda key: len(grouped[key]))
    return grouped[majority][0]


class EnsembleSamplingStrategy(SamplingStrategy):
    def __init__(
        self,
        max_concurrency: int | None = None,
        panic_on_first_model_error: bool = False,
    ) -> None:
        self.max_concurrency: int | None = max_concurrency
        self.panic_on_first_model_error: bool = panic_on_first_model_error

    @catch_model_errors
    def execute(
        self, invocable_or_invokables: Callable[..., T] | list[Callable[..., T]]
    ) -> T:
        if (
            not isinstance(invocable_or_invokables, list)
            or len(invocable_or_invokables) < 2
        ):
            raise ValueError("Ensemble sampling requires at least 2 invocables")
        if self.panic_on_first_model_error:
            results: list[T] = list(
                func_tools.map(
                    lambda invocable: invocable(),
                    invocable_or_invokables,
                    max_concurrency=self.max_concurrency,
                )
            )
        else:
            results = execute_and_filter_model_errors(
                invocable_or_invokables, max_concurrency=self.max_concurrency
            )
        if not self.panic_on_first_model_error and len(results) == 0:
            raise SamplingError(
                "No results from ensemble sampling (all calls resulted in LLM errors)"
            )
        return get_majority(results)


class UnanimousSamplingStrategy(SamplingStrategy):
    def __init__(
        self,
        n: int = 5,
        max_concurrency: int | None = None,
        panic_on_first_model_error: bool = False,
    ) -> None:
        self.n: int = n
        self.max_concurrency: int = (
            max_concurrency if max_concurrency is not None else n
        )
        self.panic_on_first_model_error: bool = panic_on_first_model_error

    @catch_model_errors
    def execute(
        self, invocable_or_invokables: Callable[..., T] | list[Callable[..., T]]
    ) -> T:
        if self.panic_on_first_model_error:
            if isinstance(invocable_or_invokables, Callable):
                results: list[T] = list(
                    func_tools.map(
                        lambda _: invocable_or_invokables(),
                        range(self.n),
                        max_concurrency=self.max_concurrency,
                    )
                )
            else:
                results = list(
                    func_tools.map(
                        lambda invocable: invocable(),
                        invocable_or_invokables,
                        max_concurrency=self.max_concurrency,
                    )
                )
        else:
            results = execute_and_filter_model_errors(
                (
                    [lambda: invocable_or_invokables() for _ in range(self.n)]
                    if isinstance(invocable_or_invokables, Callable)
                    else invocable_or_invokables
                ),
                max_concurrency=self.max_concurrency,
            )
        if len(set(results)) > 1:
            raise SamplingError("Results are not unanimous")
        return results[0]


class SamplingError(Exception):
    pass


DEFAULT_SAMPLING_STRATEGY: SamplingStrategy = SingleSamplingStrategy()
_DEFAULT_SAMPLING_STRATEGY_LOCK: Lock = Lock()


def set_default_sampling_strategy(strategy: SamplingStrategy) -> None:
    with _DEFAULT_SAMPLING_STRATEGY_LOCK:
        global DEFAULT_SAMPLING_STRATEGY
        DEFAULT_SAMPLING_STRATEGY = strategy


def get_default_sampling_strategy() -> SamplingStrategy:
    with _DEFAULT_SAMPLING_STRATEGY_LOCK:
        return DEFAULT_SAMPLING_STRATEGY
