import numpy as np
import scipy.signal as scipysig
from numpy.typing import NDArray
from typing import Optional
from pydeepc import Data

class System(object):
    """
    Represents a dynamical system that can be simulated
    """
    def __init__(self, sys: scipysig.StateSpace, x0: Optional[NDArray[np.float64]] = None, noise_std: float = 0.5):
        """
        :param sys: a linear system
        :param x0: initial state
        :param noise_std: Standard deviation of the measurement noise
        """
        assert x0 is None or sys.A.shape[0] == len(x0), 'Invalid initial condition'
        self.sys = sys
        self.x0 = x0 if x0 is not None else np.zeros(sys.A.shape[0])
        self.u = None
        self.y = None
        self.noise_std = noise_std

    def apply_input(self, u: NDArray[np.float64]) -> Data:
        """
        Applies an input signal to the system.
        
        :param u: input signal. Needs to be of shape T x M, where T is the batch size and M is the number of features

        :return: tuple that contains the (input,output) of the system
        """
        T = len(u)
        if T > 1:
            # If u is a signal of length > 1 use dlsim for quicker computation
            t, y, x0 = scipysig.dlsim(self.sys, u, t = np.arange(T) * self.sys.dt, x0 = self.x0)
            self.x0 = x0[-1]
        else:
            y = self.sys.C @ self.x0
            self.x0 = self.sys.A @ self.x0.flatten() + self.sys.B @ u.flatten()

        y = y + self.noise_std * np.random.normal(size = T).reshape(T, 1)

        self.u = np.vstack([self.u, u]) if self.u is not None else u
        self.y = np.vstack([self.y, y]) if self.y is not None else y
        return Data(u, y)

    def get_last_n_samples(self, n: int) -> Data:
        """
        Returns the last n samples
        :param n: integer value
        """
        assert self.u.shape[0] >= n, 'Not enough samples are available'
        return Data(self.u[-n:], self.y[-n:])

    def get_all_samples(self) -> Data:
        """
        Returns all samples
        """
        return Data(self.u, self.y)

    def reset(self, data_ini: Optional[Data] = None, x0: Optional[NDArray[np.float64]] = None):
        """
        Reset initial state and collected data
        """
        self.u = None if data_ini is None else data_ini.u
        self.y = None if data_ini is None else data_ini.y
        self.x0 = x0 if x0 is not None else np.zeros(self.sys.A.shape[0])



