"""Perception anomaly detection method."""
# -*- coding: utf-8 -*-
# Author: Nassir Mohammad <drnassirmohammad@gmail.com>
# License: BSD 2 clause

import numpy as np
import math
from scipy.spatial.distance import cdist


class Perception:
    """Perception anomaly detection method.

    ***

    Note: Input data must be standardised if it is multidimensional,
    using for example: sklearn.preprocessing.StandardScaler.

    ***

    Return the anomaly label of each one-dimensional or
    multi-dimensional observation.

    For scores, the greater the score, beyond zero, the more anomalous an
    observation is.

    This function specialises to one-dimensional data when the data only has
    one feature/column.

    The fundamental formula for scoring the unexpectedness of an observation n
    is:

    (S choose n) * 1/(W^{n-1})

    However, this formula is transformed for computational reasons as detailed
    in the accompanying paper and algorithm description to:

    -1/S * (log(S choose n) - (n - 1) * log(W))

    In fact the following is currently used in case of division by zero.
    -1 * (log(S choose n) - (n - 1) * log(W))

    In the multi-dimensional case:

       We take in standardised data and calculate each observations' distance
       from the median using a distance measure, for example Mahalanobis or
       Euclidean. Then, given the one-dimensional data, we apply the
       one-dimensional anomaly detection steps.

    Assumptions on data distribution
    --------------------------------
    The Perception algorithm detects anomalies in one or more dimensions under
    the assumption that:

    'normal' data is found within one majority grouping;

    the anomalies are both rare and relatively far from the main group;

    the feature values are numerical;

    the input is a numpy array: rows correspond to each observation, and
    columns correspond to each feature.

    the data has already been normalised to zero mean and standard
    deviation of 1 for all features.

    Parameters
    ----------
    max_decimal_accuracy : int, optional (default=4)
        The maximum number of decimal places to use for accuracy at the
        one dimensional computation steps

    multi_dim_distance_metric : str, optional, (default=euclidean)
        The distance metric to use between points in multidimensional space.
        e.g., 'euclidean', 'Mahalanobis'.

    multi_dim_method : str, optional (default=Direct)
        The multi-dimensional data method to use. e.g., "Direct", "indpendent".
        These will be implemented in future.

    Attributes
    ----------
    multi_d_medians_ : numpy array of shape (1, n_dimensions)
        The mutli-dimensional medians from training data columns,
        used in predict stage.

    rounding_multiplier_ : integer
        The value to multiply rounded input by (10 ** rounding_multiplier) to
        ensure list of numbers are integers on the same scale, used in predict
        stage.

    training_median_ : integer
        The 1-dimensional data median obtained from training data,
        used at predict.

    S_ : integer
        The total sum of "indicators", i.e. the sum of the input values
        after integerisation using the rounding_multiplier.

    W_ : integer
        The number of integers (or Windows or length of input data).

    scores_ : numpy array
        The anomaly scores of the predicted data. The higher, the more
        abnormal. Anomalies tend to have higher scores. 

    labels_ : numpy array of either 0 or 1
        The binary decision labels for each example: 0 normal, 1 anomaly. This
        is obtained (effectively) free from parameter tuning.

    anomalies_ : numpy array
        The anomalies from the original input data that are detected by the
        algorithm.

    """

    def __init__(self,
                 max_decimal_accuracy=4,
                 multi_dim_distance_metric='euclidean',
                 multi_dim_method='Direct'):

        self.max_decimal_accuracy = max_decimal_accuracy
        self.multi_dim_distance_metric = multi_dim_distance_metric
        self.multi_dim_method = multi_dim_method


    def fit(self, X):
        """Fit detector.

        Parameters
        ----------
        X : numpy array of shape (n_samples, n_features)
            The input samples.

        Returns
        -------
        self : object
            Fitted estimator.
        """
        assert type(X) == np.ndarray, "X must by a numpy array"
        assert X.size > 0, "numpy array must be non-empty"
        assert X.ndim < 3, "array can only be 1 or 2 dimensional"

        # if dimensionality is 1, X.shape[1] not callable
        X, dimensionality = self._check_and_process_dimensionality(X)

        if self.multi_dim_method == 'Direct':

            # in the case of multidimensional data, transform data to 1D data
            if dimensionality > 1:

                # calculate the multidimensional median
                self.multi_d_medians_ = np.median(X, axis=0)
                self.multi_d_medians_ = self.multi_d_medians_.reshape(
                    1, np.size(self.multi_d_medians_))

                X = cdist(X,
                          self.multi_d_medians_,
                          self.multi_dim_distance_metric
                          ).ravel()

            # round and multiply to achieve the required decimal accuracy and integers
            self.rounding_multiplier_ = self._get_rounding_multiplier(X, self.max_decimal_accuracy)
            Xg = self._round_scale(X, self.max_decimal_accuracy, self.rounding_multiplier_)

            # find the median of the data (round again for safety to be an integer)
            self.training_median_ = np.round(np.median(Xg))

            # take the distance from the median for all points
            Xf = np.abs(Xg - self.training_median_)

            # take the sum of all the "indicators"
            self.S_ = Xf.sum()

            # get the number of "windows" in the one-dimensional data
            self.W_ = len(Xf)

            return self


    def predict(self, X):
        """Predict if a particular sample is an anomaly or not.

        ----------
        X : numpy array of shape (n_samples, n_features).

        Returns
        -------
        labels_ : numpy array of shape (n_samples,)
            The binary decision labels for each example: 0 normal, 1 anomaly.
        """
        assert type(X) == np.ndarray, "X must by a numpy array"
        assert X.size > 0, "numpy array must be non-empty"

        assert self.S_ >= 0, 'S must be greater than or equal to 0'
        assert self.W_ > 0,'W, number of windows, must be greater than 0'

        assert X.ndim < 3, "array can only be 1 or 2 dimensional"

        # get the dimensionality of the data

        # if dimensionality is 1, X.shape[1] not callable
        X, dimensionality = self._check_and_process_dimensionality(X)

        if self.S_ > 0:
            logS_ = math.log(self.S_)
        else:
            logS_ = 0

        # removed (-1/self.S_) in case of division by zero
        sigma = -1
        logW_ = math.log(self.W_)

        if self.multi_dim_method == 'Direct':

            # keep original X for recovering detected anomalies
            if dimensionality > 1:
                Xr = cdist(X,
                           self.multi_d_medians_,
                           self.multi_dim_distance_metric
                           ).ravel()
            else:
                Xr = X

            # round and multiply to get integers on same scale as training data
            Xg = self._round_scale(Xr, self.max_decimal_accuracy, self.rounding_multiplier_)

            # self.training_median_ is an integer
            Xf = np.abs(Xg - self.training_median_)

            # S choose 0 == 1, S choose S == 1, hence log(1)=0
            # n>S: function E(C_n) is actually undefined over this range,
            # but will compute to be anomalous

            # using log n! = n*log(n) - n
            # simplified approx of log (S_C_n) using sterling approx gives:
            # S*logS - n*logn - (S-n)*log(S-n)
            with np.errstate(invalid='ignore', divide='ignore'):

                # is n = 0, the approx is 0
                # if S = n, the approx is 0
                # if n > S, the approx must be 0 artificially

                # part A
                SlogS = self.S_*logS_

                # part B
                logXf = np.where(Xf > 0, np.log(Xf), 0)
                XflogXf = Xf * logXf

                # part C
                S_Xf = self.S_ - Xf
                log_S_Xf = np.where(S_Xf > 0, np.log(S_Xf), 0)
                S_XflogS_Xf = S_Xf * log_S_Xf

                # A - B - C
                pre_logS_Choose_Xf = SlogS - XflogXf - S_XflogS_Xf

                # 0 out any values where n>S (i.e. where S_Xf is non-positive)
                mask = np.where(S_Xf < 0, 1, 0)
                logS_Choose_Xf = np.where(mask == 1, 0, pre_logS_Choose_Xf)

            # formula: -1/S * (log(S choose n) - (n - 1) * log(W))
            # removed (-1/self.S_) in case of division by zero
            self.scores_ = sigma*(logS_Choose_Xf - (Xf-1)*logW_)

            # label each score > 1 as anomaly, otherwise leave as 0
            self.labels_ = np.where(self.scores_ > 0, 1, 0)

            # get the anomalies form the original data
            self.anomalies_ = np.array(X[np.where(self.labels_ == 1)])

            return self.labels_


    def fit_predict(self, X):
        """Run fit and predict functions.

        Parameters
        ----------
        X : numpy array of shape (n_samples, n_features)
            The input samples.

        Returns
        -------
        labels_ : numpy array of shape (n_samples,)
            The binary decision labels for each example: 0 normal, 1 anomaly.
        """
        self.fit(X)
        return self.predict(X)


    @staticmethod
    def _check_and_process_dimensionality(X):
        if X.ndim == 1:
            dimensionality = 1
            X_processed = X
        elif X.ndim == 2 and X.shape[1] == 1:
            X_processed = np.squeeze(X, axis=1)
            dimensionality = 1
        elif X.ndim == 2 and X.shape[1] > 1:
            X_processed = X
            dimensionality = 2
        else:
            raise ValueError("Input array must be 1D or 2D with at least one feature.")

        return X_processed, dimensionality


    @staticmethod
    def _get_rounding_multiplier(data, accuracy):
        """
        Determine the minimal power of 10 needed to convert a list of
        floating-point numbers into integers without losing the specified
        decimal accuracy.

        This function rounds the data to the specified accuracy, scales it up,
        and finds the lowest power of 10 by which all numbers can still be 
        expressed as integers.

        Parameters
        ----------
        data : numpy array
            The input numerical data (1D or flattened from multidimensional).

        accuracy : int
            The number of decimal places to preserve when integerising the data.

        Returns
        -------
        min_exponent : int
            The minimal power of 10 required to multiply the scaled data so that
            all numbers become integers without losing the specified decimal accuracy.

        Notes
        -----
        - This is used internally in the Perception algorithm to integerise
          numbers while preserving relative differences.
        - The loop iterates at most `accuracy` times, independent of the array size.
        """
        # 1. Round data to the max accuracy
        data_rounded = np.round(data, accuracy)

        # 2. Scale it up by the max factor (10**accuracy)
        scale_max = 10 ** accuracy
        temp_scaled = np.rint(data_rounded * scale_max).astype(np.int64)

        # 3. Find the lowest power of 10 that still divides all numbers
        min_exponent = accuracy
        for exp in range(accuracy, 0, -1):
            if np.all(temp_scaled % 10 == 0):
                temp_scaled = temp_scaled // 10
                min_exponent -= 1
            else:
                break
        
        return min_exponent


    @staticmethod
    def _round_scale(data, accuracy, rounding_multiplier):

        # Step 1: Shift decimal point to preserve accuracy and round to nearest integer
        scaled = np.rint(data * (10 ** accuracy))
        
        # Step 2: Multiply by rounding multiplier and convert to int64
        integerised_data = (scaled * (10 ** rounding_multiplier)).astype(np.int64)
        
        return integerised_data

