"""
Fractional differentiation is a technique to make a time series stationary but also
retain as much memory as possible.  This is done by differencing by a positive real
number. Fractionally differenced series can be used as a feature in machine learning
process.
"""

import numpy as np
import pandas as pd


def get_weights(diff_amt: float, size: int) -> np.ndarray:
    """
    The helper function generates weights that are used to compute fractionally
    differentiated series. It computes the weights that get used in the computation
    of fractionally differentiated series. This generates a non-terminating series
    that approaches zero asymptotically. The side effect of this function is that
    it leads to negative drift "caused by an expanding window's added weights"
    (see page 83 AFML).

    When `diff_amt` is real (non-integer) positive number then it preserves memory.

    The book does not discuss what should be expected if `d` is a negative real
    number. Conceptually (from set theory) negative `d` leads to set of negative
    number of elements. And that translates into a set whose elements can be
    selected more than once or as many times as one chooses (multisets with
    unbounded multiplicity) - see http://faculty.uml.edu/jpropp/msri-up12.pdf.

    Parameters
    ----------
    diff_amt : float
        Differencing amount.
    size : int
        Length of the series.

    Returns
    -------
    weights : np.ndarray
        Weight vector.

    Notes
    -----
    Reference: Advances in Financial Machine Learning, Chapter 5, section 5.4.2, page 78.
    """

    # The algorithm below executes the iterative estimation (section 5.4.2, page 78)
    weights = [1.0]  # create an empty list and initialize the first element with 1.0.
    for k in range(1, size):
        weights_ = -weights[-1] * (diff_amt - k + 1) / k  # compute the next weight
        weights.append(weights_)

    # Now, reverse the list, convert into a numpy column vector
    weights = np.array(weights[::-1]).reshape(-1, 1)
    return weights


def frac_diff(series: pd.Series, diff_amt: float, thresh: float = 0.01) -> pd.DataFrame:
    """
    The steps are as follows:
    - Compute weights (this is a one-time exercise).
    - Iteratively apply the weights to the price series and generate output points.

    This is the expanding window variant of the `fracDiff` algorithm.
    Note 1: For thresh-1, nothing is skipped.
    Note 2: `diff_amt` can be any positive fractional, not necessarility bounded [0, 1].

    Parameters
    ----------
    series : pd.Series
        A time series that needs to be differenced
    diff_amt : float
        Differencing amount
    thresh : float
        Threshold or epsilon

    Returns
    -------
    pd.DataFrame
        Differenced series

    Notes
    -----
    Advances in Financial Machine Learning, Chapter 5, section 5.5, page 83.

    References:

    * https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086
    * https://wwwf.imperial.ac.uk/~ejm/M3S8/Problems/hosking81.pdf
    * https://en.wikipedia.org/wiki/Fractional_calculus
    """

    # 1. Compute weights for the longest series
    weights = get_weights(diff_amt, series.shape[0])

    # 2. Determine initial calculations to be skipped based on weight-loss threshold
    weights_ = np.cumsum(abs(weights))
    weights_ /= weights_[-1]
    skip = weights_[weights_ > thresh].shape[0]

    # 3. Apply weights to values
    output_df = {}
    for name in series.columns:
        series_f = series[[name]].ffill().dropna()
        output_df_ = pd.Series(index=series.index, dtype="float64")

        for iloc in range(skip, series_f.shape[0]):
            loc = series_f.index[iloc]

            # At this point all entries are non-NAs so no need for the following check
            # if np.isfinite(series.loc[loc, name]):
            output_df_[loc] = np.dot(weights[-(iloc + 1) :, :].T, series_f.loc[:loc])[
                0, 0
            ]

        output_df[name] = output_df_.copy(deep=True)
    output_df = pd.concat(output_df, axis=1)
    return output_df


def get_weights_ffd(diff_amt: float, thresh: float, lim: int) -> np.ndarray:
    """
    The helper function generates weights that are used to compute fractionally
    differentiate dseries. It computes the weights that get used in the computation
    of fractionally differentiated series. The series is of fixed width and same
    weights (generated by this function) can be used when creating fractional
    differentiated series.
    This makes the process more efficient. But the side-effect is that the
    fractionally differentiated series is skewed and has excess kurtosis. In
    other words, it is not Gaussian any more.

    The discussion of positive and negative d is similar to that in get_weights
    (see the function get_weights).

    Parameters
    ----------
    diff_amt : float
        Differencing amount.
    thresh : float
        Threshold for minimum weight.
    lim : int
        Maximum length of the weight vector.

    Returns
    -------
    weights : np.ndarray
        Weight vector.

    Notes
    -----
    Reference: Advances in Financial Machine Learning, Chapter 5, section 5.4.2, page 78.
    """

    weights = [1.0]
    k = 1

    # The algorithm below executes the iterativetive estimation (section 5.4.2, page 78)
    # The output weights array is of the indicated length (specified by lim)
    ctr = 0
    while True:
        # compute the next weight
        weights_ = -weights[-1] * (diff_amt - k + 1) / k

        if abs(weights_) < thresh:
            break

        weights.append(weights_)
        k += 1
        ctr += 1
        if ctr == lim - 1:  # if we have reached the size limit, exit the loop
            break

    # Now, reverse the list, convert into a numpy column vector
    weights = np.array(weights[::-1]).reshape(-1, 1)
    return weights


def frac_diff_ffd(
    series: pd.Series, diff_amt: float, thresh: float = 1e-5
) -> pd.DataFrame:
    """
    The steps are as follows:

    - Compute weights (this is a one-time exercise)
    - Iteratively apply the weights to the price series and generate output points

    Constant width window (new solution)
    Note 1: thresh determines the cut-off weight for the window
    Note 2: `diff_amt` can be any positive fractional, not necessarity bounded [0, 1].

    Parameters
    ----------
    series : pd.Series
        A time series that needs to be differenced.
    diff_amt : float
        Differencing amount.
    thresh : float, optional
        Threshold for minimum weight, by default 1e-5.

    Returns
    -------
    pd.DataFrame
        A data frame of differenced series.

    Notes
    -----
    Advances in Financial Machine Learning, Chapter 5, section 5.5, page 83.

    References:

    * https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086
    * https://wwwf.imperial.ac.uk/~ejm/M3S8/Problems/hosking81.pdf
    * https://en.wikipedia.org/wiki/Fractional_calculus
    """

    # 1) Compute weights for the longest series
    weights = get_weights_ffd(diff_amt, thresh, series.shape[0])
    width = len(weights) - 1

    # 2) Apply weights to values
    # 2.1) Start by creating a dictionary to hold all the fractionally differenced series
    output_df = {}

    # 2.2) compute fractionally differenced series for each stock
    for name in series.columns:
        series_f = series[[name]].ffill().dropna()
        temp_df_ = pd.Series(index=series.index, dtype="float64")
        for iloc1 in range(width, series_f.shape[0]):
            loc0 = series_f.index[iloc1 - width]
            loc1 = series.index[iloc1]

            # At this point all entries are non-NAs, hence no need for the following check
            # if np.isfinite(series.loc[loc1, name]):
            temp_df_[loc1] = np.dot(weights.T, series_f.loc[loc0:loc1])[0, 0]

        output_df[name] = temp_df_.copy(deep=True)

    # transform the dictionary into a data frame
    output_df = pd.concat(output_df, axis=1)
    return output_df


# def get_weights(diff_amt, size):
#     """ This is a pass-through function to the `get_weights` function. """
#     return FractionalDifferentiation.get_weights(diff_amt, size)


# def frac_diff(series, diff_amt, thresh=0.01):
#     """ This is a pass-through function to the `frac_diff` function. """
#     return FractionalDifferentiation.frac_diff(series, diff_amt, thresh)


# def get_weights_ffd(diff_amt, thresh, lim):
#     """ This is a pass-through function to the `get_weights_ffd` function. """
#     return FractionalDifferentiation.get_weights_ffd(diff_amt, thresh, lim)


# def frac_diff_ffd(
#     series: pd.Series,
#     diff_amt: float,
#     thresh: float = 1e-5
# ) -> pd.DataFrame:
#     """ This is a pass-through function to the `frac_diff_ffd` function. """
#     return FractionalDifferentiation.frac_diff_ffd(series, diff_amt, thresh)
