""" Detection method that is based on finding outliers in deltas of function exclusive (self) times
(i.e., function duration without the duration of its callee functions). The exclusive time outliers
method does not expect any pre-computed models and works on profiles generated by the Tracer
collector.

We use three different methods for detecting the outliers:
    1. `Modified z-score`
    2. `IQR multiple`
    3. `Standard deviation multiple`

The outliers identified by the mod. z-score are regarded as ``Severe Optimization`` or
``Severe Degradation`` changes due to them being very distant from the expected values.

The outliers identified by the IQR multiple are regarded as ordinary ``Degradation`` or
``Optimization``.

The outliers found by the stddev multiple are rather insignificant, thus we report them as only
``Maybe Degradation`` or ``Maybe Optimization``.

This method utilizes two configuration values from the perun config:

    - **degradation.location_filter**: regex used to filter the checked locations (binaries),
    - **degradation.cutoff**: float value that defines the cut-off threshold for relative
      degradation rate (total location exclusive time delta in %)

Note that this method has certain limitations that stem from the usage of outliers. It might
not work properly with certain distributions of delta values.
However, we always report the ``Total Degradation`` or ``Total Optimization``, thus even in such
cases, the user is informed about the total change and may utilize other, more suitable, detection
method (e.g., the :ref:`degradation-method-aat`).

  - **Detects**: `Exclusive time` changes; ``Optimization`` and ``Degradation``.
  - **Confidence**: `IQR multiple` for severe and ordinary changes and `StdDev multiple` for
    potential changes.
  - **Limitations**: Profiles collected by :ref:`collectors-trace`.

An example of the `ETO` method output ::

    Python 3.11.0a7

    ...
    at _ctypes.cpython-31:
    └ 136.92ms (9.19%): time Total Degradation base: 1353.431 targ: 1490.351
        (with confidence N/A = 0.0)
    at _ctypes_callproc:
    └ 2.84ms (0.19%): time Degradation base: 589.177 targ: 592.02
        (with confidence IQR multiple = 5.48)
    at _ctypes_get_fielddesc:
    └ 52.9ms (3.55%): time Severe Degradation base: 76.473 targ: 129.378
        (with confidence IQR multiple = 110.46)
    at _ctypes_init_fielddesc:
    └ 77.95ms (5.23%): time Not in Baseline base: 0.0 targ: 77.953
        (with confidence IQR multiple = 162.98)
    ...

    10 changes | +--
    optimization(+), 3 degradations(-)

In the example above, we detected a ``Severe Degradation`` in function ``_ctypes_get_fielddesc``
compared to the previous version profile (`v3.10.4`). The absolute exclusive time difference is
`52.9ms` (from `76.473ms` to `129.378ms`) and the relative difference of `3.55%` represents the
overall slowdown of the program (in this case, the CPython ctypes library). The confidence is
reported as the IQR multiple of `110.46`.

"""

from typing import Optional, Generator, Dict, List, Tuple
from difflib import get_close_matches

import pandas as pd
import numpy as np

from perun.utils.structs import DegradationInfo
from perun.check.factory import PerformanceChange
from perun.profile import convert
from perun.logic import config

from perun.profile.factory import Profile


__author__ = 'Jiri Pavela'


OldLocMap = Dict[str, str]
NewLocMap = Dict[str, str]


MZS_CORRECTION = 0.6745
MZS_CUTOFF = 3.5
IQR_CUTOFF = 1.5
STDDEV_CUTOFF = 2.0
NS_TO_MS = 1000000


def exclusive_time_outliers(
        baseline_profile: Profile, target_profile: Profile, **_
) -> Generator[DegradationInfo, None, None]:
    """Checks the pair of (baseline, target) profiles for changes in function exclusive times.

    The method works by detecting 'exclusive time delta' outliers and classifying their severity
    based on the outliers detection method that found them.

    :param baseline_profile: baseline against which we are checking the degradation
    :param target_profile: profile corresponding to the checked minor version

    :return: a generator of found performance changes
    """
    diff_prof = DiffProfile(baseline_profile, target_profile)
    yield from diff_prof.detect_changes()


class DiffProfile:
    """ A representation of a difference profile.

    Contains a merge of the baseline and target profiles, as well as their exclusive time deltas
    (absolute and relative).

    The difference profile is represented as Pandas DataFrame with columns
    [
        'uid',                      - Function name
        'location',                 - Binary that contains the function
        '-exclusive T [ms]',        - Exclusive time as recorded in baseline
        '+exclusive T [ms]',        - Exclusive time as recorded in target
        'exclusive T Δ [ms]',       - Absolute exclusive time delta
        'loc exclusive T Δ [%]'    - Relative exclusive time delta
        'Modified Z-score'          - Modified Z-score value
        'IQR multiple'              - IQR multiple, nan if below 1.5 multiple
        'StdDev_multiple'           - Standard deviation multiple
        'AD'                        - Absolute deviation of exclusive time, used for MAD computation
        'Mzs flag'                  - True if the change is flagged as mod. Z-score outlier
        'IQR flag'                  - True if the change is flagged as IQR outlier
        'StdDev flag'               - True if the change is flagged as standard deviation outlier
        'NewDel flag'               - True if the function is new or deleted in the target profile
    ]

    :ivar location_filter: detect changes only in functions from the locations (binaries)
    :ivar cut_off: report only those exclusive time changes that are above the cut_off threshold
                   of 'loc exclusive T Δ [%]'
    :ivar df: the difference profile as specified above
    """
    def __init__(self, baseline_profile: Profile, target_profile: Profile) -> None:
        """Constructor.

        :param baseline_profile: baseline against which we are checking the degradation
        :param target_profile: profile corresponding to the checked minor version
        """
        self.location_filter: Optional[str] = config.lookup_key_recursively(
            "degradation.location_filter", "*"
        )
        if self.location_filter == "*":
            self.location_filter = None
        self.cut_off: Optional[float] = float(config.lookup_key_recursively(
            "degradation.cutoff", "0.0")
        )
        self.df: pd.DataFrame = self._merge_and_diff(
            self._prepare_profile(baseline_profile), self._prepare_profile(target_profile)
        )

    def detect_changes(self) -> Generator[DegradationInfo, None, None]:
        """Detect and report the exclusive time changes and the overall degradation / optimization.

        The method runs three outlier detection methods, where z-score flags the most significant
        outliers and StdDev flags the least significant ones.

        The method also identifies the new and deleted functions in the target profile, since even
        those functions may be the source of new degradations.

        Lastly, the method reports the overall per-location degradation or optimization.

        :return: a generator of found performance changes
        """
        self.modified_z_score()
        self.iqr_multiple()
        self.std_dev_multiple()
        self.new_deleted_functions()

        # Transform the DataFrame to DegradationInfo for each function in the DF
        for _, row in self.df.iterrows():
            # Do not report changes that are below the cutoff threshold
            if -self.cut_off < row['loc exclusive T Δ [%]'] < self.cut_off:
                continue
            # Determine the severity and confidence of the changes
            result, ct, cr = self._determine_result_and_confidence(row)
            # Report the changes of function exclusive times
            yield DegradationInfo(
                res=result,
                loc=row['uid'],
                fb=str(0.0 if pd.isnull(row['-exclusive T [ms]']) else
                       round(row['-exclusive T [ms]'], 3)),
                tt=str(0.0 if pd.isnull(row['+exclusive T [ms]']) else
                       round(row['+exclusive T [ms]'], 3)),
                t='time',
                rd=row['exclusive T Δ [ms]'],
                rdr=row['loc exclusive T Δ [%]'],
                ct=ct,
                cr=round(cr, 2)
            )

        # Provide a summary degradation / optimization info per location (binary)
        columns = [
            'location',
            '-exclusive T [ms]', '+exclusive T [ms]',
            'exclusive T Δ [ms]', 'loc exclusive T Δ [%]'
        ]
        for _, row in self.df[columns].groupby(['location']).sum().reset_index().iterrows():
            if row['loc exclusive T Δ [%]'] > 0:
                result = PerformanceChange.TotalDegradation
            else:
                result = PerformanceChange.TotalOptimization
            yield DegradationInfo(
                res=result,
                loc=row['location'],
                fb=round(row['-exclusive T [ms]'], 3),
                tt=round(row['+exclusive T [ms]'], 3),
                t='time',
                rd=row['exclusive T Δ [ms]'],
                rdr=row['loc exclusive T Δ [%]'],
                ct='N/A'
            )

    @staticmethod
    def _determine_result_and_confidence(row: pd.Series) -> Tuple[PerformanceChange, str, float]:
        """Select the severity, confidence type and confidence rate of the exclusive time change.

        :param row: DataFrame row of specific 'uid' (i.e., function) record

        :return: severity, confidence type and confidence rate
        """
        exc_time = row['exclusive T Δ [ms]']
        if row['Mzs flag']:
            if exc_time > 0:
                result = PerformanceChange.SevereDegradation
            else:
                result = PerformanceChange.SevereOptimization
            # We use IQR multiple instead of the mod. z-score to make the human comparison easier
            # ct, cr = 'Modified Z-score', row['Modified Z-score']
            ct, cr = 'IQR_multiple', row['IQR multiple']
        elif row['IQR flag']:
            if exc_time > 0:
                result = PerformanceChange.Degradation
            else:
                result = PerformanceChange.Optimization
            ct, cr = 'IQR_multiple', row['IQR multiple']
        elif row['StdDev flag']:
            if exc_time > 0:
                result = PerformanceChange.MaybeDegradation
            else:
                result = PerformanceChange.MaybeOptimization
            ct, cr = 'StdDev_multiple', row['StdDev multiple']
        else:
            result = PerformanceChange.NoChange
            ct, cr = 'StdDev_multiple', row['StdDev multiple']

        # Update the result if the function is actually new or deleted
        if row['NewDel flag']:
            if exc_time > 0:
                result = PerformanceChange.NotInBaseline
            else:
                result = PerformanceChange.NotInTarget
        return result, ct, cr

    def modified_z_score(self) -> None:
        """Compute the modified Z-score (Mzs) for each function in the DataFrame.

        Also flag those records that are below or above the Mzs cut-off score (not the same cut-off
        score as the one that can be configured in config).

        Modified z-score mzs_i = (x_i − x_median) / (k * MAD)
          - MAD is the median of the absolute deviation from the median.
          - k is a consistency correction
          - x_i is the function exclusive time delta
          - x_median is the median of exclusive time deltas

        General introduction:
        https://www.kaggle.com/code/jainyk/anomaly-detection-using-zscore-and-modified-zscore/notebook
        https://medium.com/analytics-vidhya/anomaly-detection-by-modified-z-score-f8ad6be62bac

        Recommendation for cut-off score 3.5
        https://hwbdocuments.env.nm.gov/Los%20Alamos%20National%20Labs/TA%2054/11587.pdf
        """
        mad = self.df['exclusive T Δ [ms]'].mad()
        median = self.df['exclusive T Δ [ms]'].median()
        self.df['AD'] = abs(self.df['exclusive T Δ [ms]'] - median)
        try:
            self.df['Modified Z-score'] = (MZS_CORRECTION * self.df[['AD']]) / mad
        except ZeroDivisionError:
            # Make the MAD as close to 0 as possible
            mad = np.nextafter(0, 1)
            self.df['Modified Z-score'] = (MZS_CORRECTION * self.df[['AD']]) / mad
        mzs_filter = (self.df['Modified Z-score'] < -MZS_CUTOFF) | \
                     (self.df['Modified Z-score'] > MZS_CUTOFF)
        # Flag for easier filtering
        self.df['Mzs flag'] = mzs_filter

    def iqr_multiple(self) -> None:
        """Compute the IQR multiple for each function in the DataFrame.

        Also flag those records that are below or above the IQR cut-off score.
        """
        # Compute IQR
        q1 = self.df['exclusive T Δ [ms]'].quantile(0.25)
        q3 = self.df['exclusive T Δ [ms]'].quantile(0.75)
        iqr = q3 - q1
        # Create an IQR filter based on the distance of IQR_CUTOFF * IQR from Q1 and Q3
        below_low_base = self.df['exclusive T Δ [ms]'] < q1 - IQR_CUTOFF * iqr
        above_up_base = self.df['exclusive T Δ [ms]'] > q3 + IQR_CUTOFF * iqr
        iqr_filter = (below_low_base | above_up_base)
        # Compute the IQR multiple for outliers
        self.df.loc[below_low_base, 'IQR multiple'] = -((self.df['exclusive T Δ [ms]'] - q1) / iqr)
        self.df.loc[above_up_base, 'IQR multiple'] = self.df['exclusive T Δ [ms]'] / iqr - q3
        # Flag for easier filtering
        self.df['IQR flag'] = iqr_filter

    def std_dev_multiple(self) -> None:
        """Compute the StdDev multiple for each function in the DataFrame.

        Also flag those records that are below or above the StdDev cut-off score.
        Note that we compute the stddev without the previously detected outliers that would
        otherwise heavily skew the stddev value.
        """
        df_filtered = self.df.loc[~(self.df['Mzs flag']) & ~(self.df['IQR flag'])]
        stddev = df_filtered['exclusive T Δ [ms]'].std()
        self.df['StdDev multiple'] = self.df['exclusive T Δ [ms]'] / stddev
        stddev_filter = (self.df['StdDev multiple'] < -STDDEV_CUTOFF) | \
                        (self.df['StdDev multiple'] > STDDEV_CUTOFF)
        self.df['StdDev flag'] = stddev_filter

    def new_deleted_functions(self) -> None:
        """Flag functions that are new or missing in the target profile."""
        new_removed_filter = (
                self.df['+exclusive T [ms]'].isna() | self.df['-exclusive T [ms]'].isna()
        )
        self.df['NewDel flag'] = new_removed_filter

    def _prepare_profile(self, profile: Profile) -> pd.DataFrame:
        """Extract the profile resources as DataFrame in the desired format.

        Namely:
        1) keep only the 'uid' (function name), 'exclusive' (time) and 'location' columns
        2) sum all of the individual exclusive time records
        3) filter the location based on the supplied regex

        :param profile: standard perun representation of a profile

        :return: an appropriately formatted DataFrame
        """
        columns = ['uid', 'exclusive', 'location']
        # Obtain "Uid (function name), exclusive time, location" DataFrame
        # and sum the exclusive times of individual functions
        df = convert.resources_to_pandas_dataframe(profile)[columns]\
            .groupby(['uid', 'location']).sum().reset_index()
        # Filter the location based on the provided regex filter
        if self.location_filter is not None:
            return df[df['location'].str.contains(self.location_filter, regex=True, na=False)]
        return df

    @staticmethod
    def _merge_and_diff(
            baseline_profile: pd.DataFrame, target_profile: pd.DataFrame
    ) -> pd.DataFrame:
        """Merge the baseline and target DataFrames and compute the absolute and relative deltas.

        :param baseline_profile: baseline profile DataFrame
        :param target_profile: baseline profile DataFrame

        :return: the merged and extended DataFrame
        """
        def _delta_exc(row: pd.Series) -> float:
            """ Helper function for properly computing the absolute exclusive time delta even
            when the function is new / deleted (in those cases, one of the exclusive times is
            nan, which breaks the computation).

            :param row: a specific DataFrame row

            :return: absolute exclusive time delta
            """
            exc_new = 0.0 if pd.isnull(row['+exclusive T [ms]']) else row['+exclusive T [ms]']
            exc_old = 0.0 if pd.isnull(row['-exclusive T [ms]']) else row['-exclusive T [ms]']
            return exc_new - exc_old

        # Rename the exclusive time columns appropriately (- for old, + for new) and merge them
        baseline_profile.rename(columns={'exclusive': '-exclusive T [ms]'}, inplace=True)
        target_profile.rename(columns={'exclusive': '+exclusive T [ms]'}, inplace=True)
        # Convert ns to ms
        # TODO: dynamic conversion to the most appropriate unit (e.g., seconds, us, ...)
        baseline_profile['-exclusive T [ms]'] = baseline_profile['-exclusive T [ms]'].div(NS_TO_MS)
        target_profile['+exclusive T [ms]'] = target_profile['+exclusive T [ms]'].div(NS_TO_MS)
        # Rename the locations in the baseline and target profiles to match. We employ a string
        # similarity check to discover possible changes of binaries name, e.g., due to version num.
        rename_old, rename_new = _map_similar_names(
            list(baseline_profile['location'].unique()), list(target_profile['location'].unique())
        )
        baseline_profile['location'].replace(rename_old, inplace=True)
        target_profile['location'].replace(rename_new, inplace=True)

        df_merge = pd.merge(target_profile, baseline_profile, on=['uid', 'location'], how='left')
        # Compute the exclusive time diff
        df_merge['exclusive T Δ [ms]'] = df_merge.apply(_delta_exc, axis=1)
        # Prepare filters
        new_nan_filt = df_merge['+exclusive T [ms]'].isna()
        old_nan_filt = df_merge['-exclusive T [ms]'].isna()
        no_nan_filt = ~(df_merge['-exclusive T [ms]'].isna() | df_merge['-exclusive T [ms]'].isna())
        # Compute the sum of all exclusive times
        total_exc = df_merge['+exclusive T [ms]'].sum()
        # Compute the impact of change on the total location exclusive time
        df_merge.loc[new_nan_filt, 'loc exclusive T Δ [%]'] = (
                df_merge['-exclusive T [ms]'] / total_exc * (-100)
        )
        df_merge.loc[old_nan_filt, 'loc exclusive T Δ [%]'] = (
                df_merge['+exclusive T [ms]'] / total_exc * 100
        )
        df_merge.loc[no_nan_filt, 'loc exclusive T Δ [%]'] = (
                (df_merge['+exclusive T [ms]'] - df_merge['-exclusive T [ms]']) / total_exc * 100
        )

        # Sort by the most significant time difference
        return df_merge.sort_values(by='exclusive T Δ [ms]', ascending=False).reset_index(drop=True)


def _map_similar_names(
        strings_old: List[str], strings_new: List[str]
) -> Tuple[OldLocMap, NewLocMap]:
    """Map profile location names that might have slightly changed in different versions.

    E.g., due to the names containing the version number (mylib-3.4 vs mylib-3.5).

    :param strings_old: a collection of location names found in the previous version
    :param strings_new: a collection of location names found in the current version

    :return: mapping of 1) old and 2) new location names to the newly created common names
             (e.g., mylib-3.4 -> mylib-3._ and mylib-3.5 -> mylib-3._)
    """
    renames_old, renames_new = {}, {}
    for old_name in strings_old:
        matching_name = get_close_matches(old_name, strings_new, n=1)
        # If no match was found, no rename will be done
        if matching_name:
            # We found a match and now we want to find the longest common prefix
            match = str(matching_name[0])
            new_name = _longest_common_prefix(old_name, match)
            # Update the rename map
            renames_old[old_name] = new_name
            renames_new[str(match)] = new_name
            # Remove the already matched name
            strings_new.remove(str(match))
    return renames_old, renames_new


def _longest_common_prefix(string1: str, string2: str) -> str:
    """Find longest common prefix of two strings.

    :param string1: the first string
    :param string2: the second string

    :return: the longest common prefix of string1 and string2
    """
    # TODO: find all common substrings and concatenate them to create the name
    prefix = string1
    for idx, (char1, char2) in enumerate(zip(string1, string2)):
        if char1 != char2:
            prefix = string1[:idx]
    return prefix
