"""
You can configure the order creation/generation via the `environment_config.yml`.

It is very configurable by letting you reference own Python classes and functions.

```yaml
orders:
  serving_not_ordered_meals: null
  order_gen_class:  !!python/name:cooperative_cuisine.orders.RandomOrderGeneration ''
  order_gen_kwargs:
    ...
```

`serving_not_ordered_meals` expects a function. It received a meal as an argument and should return a
tuple of a bool and the score. If the bool is true, the score will be added to the score. Otherwise, it will not
accept the meal for serving.

The `order_gen_class` should be a child of the `OrderGeneration` class. The `order_gen_kwargs` depend then on your
class referenced.

This file defines the following classes:
- `Order`
- `OrderGeneration`
- `OrderManager`

Further, it defines same implementations for the basic order generation based on random sampling:
- `RandomOrderGeneration`

For an easier usage of the random orders, also some classes for type hints and dataclasses are defined:
- `RandomOrderKwarg`
- `RandomFuncConfig`


## Code Documentation
"""
from __future__ import annotations

import dataclasses
import logging
import uuid
from abc import abstractmethod
from collections import deque
from datetime import datetime, timedelta
from random import Random
from typing import Callable, Tuple, Any, Deque, TypedDict, Type, Literal

from cooperative_cuisine.hooks import (
    Hooks,
    SERVE_NOT_ORDERED_MEAL,
    SERVE_WITHOUT_PLATE,
    COMPLETED_ORDER,
    INIT_ORDERS,
    NEW_ORDERS,
    ORDER_EXPIRED,
)
from cooperative_cuisine.items import Item, Plate, ItemInfo
from cooperative_cuisine.state_representation import OrderState

log = logging.getLogger(__name__)
"""The logger for this module."""


class OrderConfig(TypedDict):
    """The configuration of the order in the `environment_config`under the `order` key."""

    meals: dict[Literal["all"] | Literal["list"], bool | list[str]]
    """Config of which meals are ordered in orders. 
    If 'all: true' all (validated) meals will be ordered, else the ones specified under 'list'."""
    order_gen_class: Type[OrderGeneration]
    """The class that should handle the order generation."""
    order_gen_kwargs: dict[str, Any]
    """The additional kwargs for the order gen class."""
    serving_not_ordered_meals: Callable[[Item], Tuple[bool, float]]
    """"""


@dataclasses.dataclass
class Order:
    """Datawrapper for Orders"""

    meal: ItemInfo
    """The meal to serve and that should be cooked."""
    start_time: datetime
    """The start time relative to the env_time. On which the order is returned from the get_orders func."""
    max_duration: timedelta
    """The duration after which the order expires."""
    uuid: str = dataclasses.field(default_factory=lambda: uuid.uuid4().hex)
    """The unique identifier for the order."""
    finished_info: dict[str, Any] = dataclasses.field(default_factory=dict)
    """Is set after the order is completed."""


class OrderGeneration:
    """Base class for generating orders.

    You can set your child class via the `environment_config.yml`.
    Example:
        ```yaml
        orders:
          order_gen_class: !!python/name:cooperative_cuisine.orders.RandomOrderGeneration ''
          kwargs:
            ...
        ```
    """

    def __init__(
        self,
        hook: Hooks,
        random: Random,
        **kwargs,
    ):
        """Constructor of OrderGeneration.

        Args:
            hook: An instance of Hooks class.
            random: An instance of Random class.
            **kwargs: Additional keyword arguments.
        """
        self.available_meals: list[ItemInfo] | None = None
        """Available meals restricted through the `environment_config.yml`."""
        self.hook: Hooks = hook
        """Reference to the hook manager."""
        self.random = random
        """Random instance."""

    @abstractmethod
    def init_orders(self, now) -> list[Order]:
        """Get the orders the environment starts with."""
        ...

    @abstractmethod
    def get_orders(
        self,
        passed_time: timedelta,
        now: datetime,
        new_finished_orders: list[Order],
        expired_orders: list[Order],
    ) -> list[Order]:
        """Orders for each progress call. Should often be the empty list."""
        ...


class OrderManager:
    """The Order and Score Manager that is called from the serving window."""

    def __init__(
        self,
        order_config,
        hook: Hooks,
        random: Random,
    ):
        """Constructor of OrderManager.
        Args:
            order_config: A dictionary containing the configuration for orders.
            hook: An instance of the Hooks class.
            random: An instance of the Random class.
        """
        self.random = random
        """Random instance."""
        self.order_gen: OrderGeneration = order_config["order_gen_class"](
            hook=hook,
            random=random,
            kwargs=order_config["order_gen_kwargs"],
        )
        """The order generation."""
        self.serving_not_ordered_meals: Callable[
            [Item], Tuple[bool, float]
        ] = order_config["serving_not_ordered_meals"]
        """Function that decides if not ordered meals can be served and what score it gives"""

        self.available_meals = None
        """The meals for that orders can be sampled from."""
        self.open_orders: Deque[Order] = deque()
        """Current open orders. This attribute is used for the environment state."""

        # TODO log who / which player served which meal -> for split scores
        self.served_meals: list[Tuple[Item, datetime, str]] = []
        """List of served meals. Maybe for the end screen."""
        self.last_finished: list[Order] = []
        """Cache last finished orders for `OrderGeneration.get_orders` call. From the served meals."""
        self.next_relevant_time: datetime = datetime.max
        """For reduced order checking. Store the next time when to create an order."""
        self.last_expired: list[Order] = []
        """Cache last expired orders for `OrderGeneration.get_orders` call."""

        self.hook: Hooks = hook
        """Reference to the hook manager."""

    def set_available_meals(self, available_meals):
        """Set the available meals from which orders can be generated.

        Args:
            available_meals (dict): A dictionary containing the available meals and their quantities.
        """
        self.available_meals = available_meals
        self.order_gen.available_meals = list(available_meals.values())

    def update_next_relevant_time(self):
        """For more efficient checking when to do something in the progress call."""
        next_relevant_time = datetime.max
        for order in self.open_orders:
            next_relevant_time = min(
                next_relevant_time, order.start_time + order.max_duration
            )
        self.next_relevant_time = next_relevant_time

    def serve_meal(self, item: Item, env_time: datetime, player: str) -> bool:
        """Is called by the ServingWindow to serve a meal. Returns True if the meal can be served and should be
        "deleted" from the hands of the player."""
        if isinstance(item, Plate):
            meal = item.get_potential_meal()
            if meal is not None:
                if meal.name in self.available_meals:
                    order = self.find_order_for_meal(meal)
                    if order is None:
                        if self.serving_not_ordered_meals:
                            self.hook(
                                SERVE_NOT_ORDERED_MEAL,
                                meal=meal,
                                meal_name=meal.name,
                            )
                            log.info(f"Serving meal without order {meal.name!r}")
                            self.served_meals.append((meal, env_time, player))
                            return True
                        log.info(
                            f"Do not serve meal {meal.name!r} because it is not ordered"
                        )
                        return False
                    order, index = order
                    log.info(f"Serving meal {meal.name!r} with order")
                    self.last_finished.append(order)
                    del self.open_orders[index]
                    self.served_meals.append((meal, env_time, player))
                    order.finished_info["remaining_time_ratio"] = (
                        order.start_time + order.max_duration - env_time
                    ).total_seconds() / order.max_duration.total_seconds()
                    self.hook(
                        COMPLETED_ORDER,
                        order=order,
                        meal=meal,
                        relative_order_time=env_time - order.start_time,
                        meal_name=meal.name,
                    )
                    return True
        else:
            self.hook(SERVE_WITHOUT_PLATE, item=item)
        log.info(f"Do not serve item {item}")
        return False

    def create_init_orders(self, env_time):
        """Create the initial orders in an environment."""
        init_orders = self.order_gen.init_orders(env_time)
        self.hook(INIT_ORDERS, init_orders=init_orders)
        self.open_orders.extend(init_orders)

        # self.update_next_relevant_time()

    def progress(self, passed_time: timedelta, now: datetime):
        """Check expired orders and check order generation."""
        new_orders = self.order_gen.get_orders(
            passed_time=passed_time,
            now=now,
            new_finished_orders=self.last_finished,
            expired_orders=self.last_expired,
        )
        if new_orders:
            self.hook(NEW_ORDERS, new_orders=new_orders)
        self.open_orders.extend(new_orders)
        self.last_finished = []
        self.last_expired = []
        if new_orders or self.next_relevant_time <= now:
            # reduce checking calls

            remove_orders: list[int] = []
            for index, order in enumerate(self.open_orders):
                if now >= order.start_time + order.max_duration:
                    # orders expired
                    self.hook(ORDER_EXPIRED, order=order)
                    remove_orders.append(index)
                    continue

            expired_orders: list[Order] = []
            for remove_order in reversed(remove_orders):
                expired_orders.append(self.open_orders[remove_order])
                del self.open_orders[remove_order]
            self.last_expired = expired_orders

            self.update_next_relevant_time()

    def find_order_for_meal(self, meal) -> Tuple[Order, int] | None:
        """Get the order that will be fulfilled for a meal. At the moment the oldest order in the list that has the
        same meal (name)."""
        for index, order in enumerate(self.open_orders):
            if order.meal.name == meal.name:
                return order, index

    def order_state(self) -> list[OrderState]:
        """Similar to the `to_dict` in `Item` and `Counter`. Relevant for the state of the environment"""
        return [
            {
                "id": order.uuid,
                "category": "Order",
                "meal": order.meal.name,
                "start_time": order.start_time.isoformat(),
                "max_duration": order.max_duration.total_seconds(),
            }
            for order in self.open_orders
        ]


class RandomFuncConfig(TypedDict):
    """Types of the dict for sampling with different random functions from the [`random` library](https://docs.python.org/3/library/random.html).

    Example:
        Sampling [uniform](https://docs.python.org/3/library/random.html#random.uniform)ly between `10` and `20`.
        ```yaml
        func: uniform
        kwargs:
          a: 10
          b: 20
        ```

        Or in Python:
        ```python
        random_func = {'func': 'uniform', 'kwargs': {'a': 10, 'b': 20}}
        ```
    """

    func: str | Callable
    """the name of a functions in the `random` library."""
    kwargs: dict
    """the kwargs of the functions in the `random` library."""


@dataclasses.dataclass
class RandomOrderKwarg:
    num_start_meals: int
    """Number of meals sampled at the start."""
    sample_on_serving: bool
    """Only sample the delay for the next order after a meal was served."""
    sample_on_dur_random_func: RandomFuncConfig
    """How to sample the delay of the next incoming order. Either after a new meal was served or the last order was 
    generated (based on the `sample_on_serving` attribute)."""
    max_orders: int
    """How many orders can maximally be active at the same time."""
    order_duration_random_func: RandomFuncConfig
    """How long the order is alive until it expires. If `sample_on_serving` is `true` all orders have no expire time."""


class RandomOrderGeneration(OrderGeneration):
    """A simple order generation based on random sampling with two options.

    Either sample the delay when a new order should come in after the last order comes in or after a meal was served
    (and an order got removed).

    To configure it align your kwargs with the `RandomOrderKwarg` class.

    You can set this order generation in your `environment_config.yml` with
    ```yaml
    orders:
      order_gen_class: !!python/name:cooperative_cuisine.orders.RandomOrderGeneration ''
      # the class to that receives the kwargs. Should be a child class of OrderGeneration in orders.py
      order_gen_kwargs:
        order_duration_random_func:
          # how long should the orders be alive
          # 'random' library call with getattr, kwargs are passed to the function
          func: uniform
          kwargs:
            a: 40
            b: 60
        max_orders: 6
        # maximum number of active orders at the same time
        num_start_meals: 2
        # number of orders generated at the start of the environment
        sample_on_dur_random_func:
          # 'random' library call with getattr, kwargs are passed to the function
          func: uniform
          kwargs:
            a: 10
            b: 20
        sample_on_serving: false
        # Sample the delay for the next order only after a meal was served.
      serving_not_ordered_meals: true
      # can meals that are not ordered be served / dropped on the serving window
    ```
    """

    def __init__(
        self,
        hook: Hooks,
        random: Random,
        **kwargs,
    ):
        """Constructor of RandomOrderGeneration.

        Args:
            hook (Hooks): The hook object.
            random (Random): The random object.
            **kwargs: Additional keyword arguments.
        """
        super().__init__(hook, random, **kwargs)
        self.kwargs: RandomOrderKwarg = RandomOrderKwarg(**kwargs["kwargs"])
        """Configuration og the RandomOrder genration. See `RandomOrderKwarg`"""
        self.next_order_time: datetime | None = datetime.max
        """For efficient checking to update order removable."""
        self.number_cur_orders: int = 0
        """How many orders are currently open."""
        self.num_needed_orders: int = 0
        """For the sample on dur but when it was restricted due to max order number."""

    def init_orders(self, now) -> list[Order]:
        self.number_cur_orders = self.kwargs.num_start_meals
        if not self.kwargs.sample_on_serving:
            self.create_random_next_time_delta(now)
        if self.available_meals:
            return self.create_orders_for_meals(
                self.random.choices(
                    self.available_meals, k=self.kwargs.num_start_meals
                ),
                now,
                self.kwargs.sample_on_serving,
            )
        self.number_cur_orders = 0
        return []

    def get_orders(
        self,
        passed_time: timedelta,
        now: datetime,
        new_finished_orders: list[Order],
        expired_orders: list[Order],
    ) -> list[Order]:
        if not self.available_meals:
            return []
        self.number_cur_orders -= len(new_finished_orders)
        self.number_cur_orders -= len(expired_orders)
        if self.kwargs.sample_on_serving:
            if new_finished_orders:
                self.create_random_next_time_delta(now)
                return []

        # print(self.number_cur_orders, self.num_needed_orders)

        if self.num_needed_orders:
            # self.num_needed_orders -= len(new_finished_orders)
            # self.num_needed_orders = max(self.num_needed_orders, 0)
            # self.number_cur_orders += len(new_finished_orders)

            return self.create_orders_for_meals(
                self.random.choices(
                    self.available_meals,
                    k=len(new_finished_orders) + len(expired_orders),
                ),
                now,
            )

        if self.next_order_time <= now:
            if self.number_cur_orders >= self.kwargs.max_orders:
                self.num_needed_orders += 1
            else:
                if not self.kwargs.sample_on_serving:
                    self.create_random_next_time_delta(now)
                else:
                    self.next_order_time = datetime.max
                self.number_cur_orders += 1
                return self.create_orders_for_meals(
                    [self.random.choice(self.available_meals)],
                    now,
                )
        return []

    def create_orders_for_meals(
        self, meals: list[ItemInfo], now: datetime, no_time_limit: bool = False
    ) -> list[Order]:
        """Create order objects for given meals by sampling the duration of the orders.

        Args:
            meals: A list of ItemInfo objects representing the meals for which orders need to be created.
            now: A datetime object representing the current env time (the start time of the orders).
            no_time_limit: A boolean indicating whether there should be no limit on the order duration. Defaults to False.

        Returns:
            A list of Order objects representing the created orders.
        """
        orders = []
        for meal in meals:
            if no_time_limit:
                duration = timedelta(days=365)
            else:
                if isinstance(self.kwargs.order_duration_random_func["func"], str):
                    seconds = getattr(
                        self.random, self.kwargs.order_duration_random_func["func"]
                    )(**self.kwargs.order_duration_random_func["kwargs"])
                else:
                    seconds = self.kwargs.order_duration_random_func["func"](
                        **self.kwargs.order_duration_random_func["kwargs"]
                    )
                duration = timedelta(seconds=seconds)
            log.info(f"Create order for meal {meal} with duration {duration}")
            orders.append(
                Order(
                    meal=meal,
                    start_time=now,
                    max_duration=duration,
                )
            )

        return orders

    def create_random_next_time_delta(self, now: datetime):
        if isinstance(self.kwargs.order_duration_random_func["func"], str):
            seconds = getattr(
                self.random, self.kwargs.sample_on_dur_random_func["func"]
            )(**self.kwargs.sample_on_dur_random_func["kwargs"])
        else:
            seconds = self.kwargs.sample_on_dur_random_func["func"](
                **self.kwargs.sample_on_dur_random_func["kwargs"]
            )

        self.next_order_time = now + timedelta(seconds=seconds)
        log.info(f"Next order in {self.next_order_time}")