from feathersdk.utils.feathertypes import Generic, TypeVar, Union, Any, Callable
import numpy as np


T = TypeVar("T")


_NO_DEFAULT = object()


class BufferEmptyError(Exception):
    """Exception raised when a buffer is empty and no default value is provided."""
    def __init__(self, message: str = "Cannot perform that operation on an empty buffer"):
        super().__init__(message)


_CircBufFuncType = Callable[['CircularBuffer[T]', Any], Any]

def _check_circ_buf_default(func: _CircBufFuncType) -> _CircBufFuncType:
    """Decorator that checks if buffer is empty and raises BufferEmptyError if so, unless default value is provided."""
    def wrapper(self: 'CircularBuffer[T]', default: Any = _NO_DEFAULT) -> Any:
        if self.count == 0:
            if default is _NO_DEFAULT:
                raise BufferEmptyError(f"Cannot compute \"{func.__name__}\" on empty buffer")
            return default
        return func(self, default)
    return wrapper


class CircularBuffer(Generic[T]):
    """A circular buffer.
    
    This is a circular buffer that stores items in a fixed-size array. When the buffer is full, the oldest item is
    overwritten. Has fast append, peek, pop-first, and pop-last operations.

    Implementation:

    We use two pointers to track the start and end of the buffer. The start pointer is the index of the earliest item
    in the buffer, and the end pointer is the index of the next item to be written. The count is the number of items in
    the buffer. We keep count separate because we need to differentiate between the buffer being empty and full, both
    of which would have the same start and end pointer.

    This allows us to have fast append, peek, pop-first, and pop-last operations, while keeping the circular nature of
    the buffer.

    Parameters
    ----------
    capacity: int
        The maximum number of items to store in the buffer.
    dtype: type
        The dtype of the items in the buffer. Use numpy-compatible dtypes. Defaults to object, which can store any type.
    """
    def __init__(self, capacity: int, dtype: type = object):
        if capacity < 1:
            raise ValueError(f"Capacity must be at least 1, got {capacity}")
        
        self.capacity = capacity
        self._buffer: 'np.ndarray[T]' = np.empty(capacity, dtype=dtype)
        self._start = 0
        self._end = 0
        self.count = 0
    
    def append(self, item: T):
        """Append an item to the buffer. If the buffer is full, the oldest item is overwritten."""
        self._buffer[self._end] = item
        if self.count == self.capacity:  # Only increment start if the buffer is full
            self._start = (self._start + 1) % self.capacity
        self._end = (self._end + 1) % self.capacity
        self.count = min(self.count + 1, self.capacity)
    
    def get_used_buffer(self) -> 'np.ndarray[T]':
        """Returns a numpy array of the items currently in the buffer, in correct order"""
        if self.count == 0:
            return np.empty(0, dtype=self._buffer.dtype)
        elif self._start < self._end:
            return self._buffer[self._start:self._end]
        return np.concatenate((self._buffer[self._start:], self._buffer[:self._end]))
    
    def _check_valid_index(self, index: int) -> int:
        if (index < -self.count) or (index >= self.count):
            raise IndexError(f"Index out of range: {index} for buffer of current size {self.count}")
        return (index + self.count) if index < 0 else index
    
    def peek(self, index: int) -> T:
        """Peek at an item in the buffer."""
        return self._buffer[(self._start + self._check_valid_index(index)) % self.capacity]
    
    def pop(self, index: int) -> T:
        """Pop an item from the buffer."""
        index = self._check_valid_index(index)
        item = self.peek(index)

        # Check if we don't have to copy memory around
        if index == 0:
            self._start = (self._start + 1) % self.capacity
        elif index == self.count - 1:
            self._end = (self._end - 1) % self.capacity
        else:
            # Note: this is pretty slow with so many copies but I don't intend to call it anytime soon
            self._buffer = self.get_used_buffer()
            self._buffer[index:-1] = self._buffer[index+1:]
            self._start, self._end = 0, self.count - 1
        
        self.count -= 1
        return item
    
    def __getitem__(self, key: Union[int, slice]) -> Union[T, np.ndarray]:
        """Support numpy-like slicing and indexing."""
        if isinstance(key, int):
            return self.peek(key)
        return self.get_used_buffer()[key]
    
    @_check_circ_buf_default
    def mean(self, default: Any = _NO_DEFAULT) -> Any:
        """Compute the mean of the items currently in the buffer. Returns the default value if the buffer is empty."""
        if self._start <= self._end:
            return np.mean(self._buffer[self._start:self._end])
        return (np.mean(self._buffer[self._start:]) + np.mean(self._buffer[:self._end])) / 2
    
    @_check_circ_buf_default
    def max(self, default: Any = _NO_DEFAULT) -> Any:
        """Compute the max of the items currently in the buffer. Returns the default value if the buffer is empty."""
        if self._start <= self._end:
            return np.max(self._buffer[self._start:self._end])
        return max(np.max(self._buffer[self._start:]), np.max(self._buffer[:self._end]))
    
    @_check_circ_buf_default
    def min(self, default: Any = _NO_DEFAULT) -> Any:
        """Compute the min of the items currently in the buffer. Returns the default value if the buffer is empty."""
        if self._start <= self._end:
            return np.min(self._buffer[self._start:self._end])
        return min(np.min(self._buffer[self._start:]), np.min(self._buffer[:self._end]))
    
    def __len__(self) -> int:
        return self.count
    
    def clear(self) -> None:
        """Clear the buffer"""
        self._start = 0
        self._end = 0
        self.count = 0