import warnings
from . import by as _by
from .parameters import parameters
import os
import numpy as np


class Tier(object):
    """An abstract Tier."""

    __slots__ = ['__points', '__start_time', '__end_time',
                 '__points_to_sort', '_indexed', '__original_dir']

    def __init__(self, start_time, end_time, name='', points=None, original_dir=''):
        self.__points = [points, []][points == None]
        self.__start_time = start_time
        self.__end_time = end_time
        self.__original_dir = original_dir
        self._indexed = True
        self.name = name
        self.tier_type = None

    def add_point(self, point):
        """Add point (Point object) to the Tier.\nUsage:\n\t(1) You can either (a) create a Point object with `Point()`, or (b) get a specific Point object from already existing Tiers with `get_point()`.\n\t(2) Pass it as the argument."""
        point = Point(point.time, point.value, superior_Tier=self)

        if point.time < self.start_time or point.time > self.end_time:
            raise Exception('Trying to set a point outside of time linits.')

        time = point.time

        for this_point in self.__points:
            if this_point.time == time:
                raise Exception(
                    f'Point object with time {time} already exists.')

        self.__points.append(point)

        self.__points_to_sort = [(point.time, point)
                                 for point in self.__points]
        self.__points = [i[1] for i in sorted(
            self.__points_to_sort, key=lambda x: x[0])]

        self._indexed = False
        self._reindex()
        self._indexed = True

    def add_points(self, points):
        """Add a list (array-like) of points (Point object) to the Tier.\nUsage:\n\t(1) You can either (a) create the Point objects with `Point()`, or (b) get specific Point objects from already existing Tiers with `get_points()`.\n\t(2) Pass it as the argument."""

        for point in points:
            point = Point(point.time, point.value, superior_Tier=self)

            if point.time < self.start_time or point.time > self.end_time:
                raise Exception(
                    'Trying to set a point outside of time linits.')

            time = point.time

            for this_point in self.__points:
                if this_point.time == time:
                    raise Exception(
                        f'Point object with time {time} already exists.')

            self.__points.append(point)

        self.__points_to_sort = [(point.time, point)
                                 for point in self.__points]
        self.__points = [i[1] for i in sorted(
            self.__points_to_sort, key=lambda x: x[0])]

        self._indexed = False
        self._reindex()
        self._indexed = True

    def to_plot(self, start_time=None, end_time=None):
        """Plot the points of the Tier between two time points. If the time points are not specified, `start time` and\\or `end time` are used. To change related parameters, use `parameters`."""

        if start_time == None:
            start_time = self.start_time
        if end_time == None:
            end_time = self.end_time

        if start_time >= end_time:
            raise Exception('start_time is larger than or equals to end_time.')

        df = self.to_dataframe()

        this_df = df[df.time >= start_time]
        this_df = this_df[this_df.time <= end_time]

        if parameters['to_plot.package'] == 'matplotlib':
            import seaborn as sns
            from matplotlib import pyplot as plt

            plt.figure(figsize=parameters['to_plot.size'])

            plot = sns.scatterplot(x='time', y='value', data=this_df)

        if parameters['to_plot.package'] == 'plotly':
            import plotly.express as px

            plot = px.scatter(this_df, x="time", y="value")
            plot.update_layout(xaxis=dict(rangeslider=dict(visible=True),
                                          type="linear"))

        return plot

    def get_point(self, by_target, by, strict=True, point_type=None):
        """Get Point of the Tier with chosen method. \n If `strict` set to False, get the closest Point object to the target. If `point_type` set to \'larger\', find the closest larger Point; If `point_type` set to \'smaller\', find the closest smaller Point.\nSee `help(by)` for more information on the available methods. To change related parameters, use `parameters`."""

        if by not in _by.by_methods:
            raise Exception(
                f'Invalid by_method. Use help() to see valid methods.')

        if by == _by.TIME:
            if strict:
                if point_type!=None:
                    warnings.warn('`point_type` is not used when `strict` is set to True.')
                for point in self.__points:
                    if point.time == by_target:
                        return point
            
            else:
                points = self.__points.copy()

                if point_type == None:
                    pass
                elif point_type == 'larger':
                    points = [point for point in points if point.time >= by_target]
                elif point_type == 'smaller':
                    points = [point for point in points if point.time <= by_target]
                else:
                    raise Exception(
                        '`point_type` can only by one of {\'larger\', \'smaller\', None}.'
                    )

                points.sort(key=lambda point: abs(point.time - by_target))

                return points[0]

        elif by == _by.INDEX:
            if point_type!=None:
                warnings.warn('`point_type` is not used when by method is not `TIME`.')
                
            try:
                point = self.__points[by_target-1]

                return point
            except IndexError:
                pass

        elif by == _by.POINT:
            for point in self.__points:
                if point == by_target:
                    return point

        if not parameters['points.ignore_missing_point']:
            if by==_by.TIME:
                raise Exception('Point not found. Consider setting `strict` to False.')
            raise Exception('Point not found.')

    def get_points(self, by_target, by):
        """Get a list of Point objects with chosen method. See `help(by)` for more information on the available methods. To change related parameters, use `parameters`."""

        points = []

        if by not in _by.by_methods:
            raise Exception(
                f'Invalid by_method. Use help() to see valid methods.')

        if by == _by.POINT:
            for point in by_target:
                if point in self.__points:
                    points.append(point)
                elif parameters['points.ignore_missing_point']:
                    pass
                else:
                    raise Exception('Point not found.')

        elif by == _by.INDEX:
            for index in range(by_target[0], by_target[1]+1):
                points.append(self.get_point(index, _by.INDEX))

        elif by == _by.TIME:
            for point in self.__points:
                if by_target[0] <= point.time <= by_target[1]:
                    points.append(point)

        return points

    def remove_point(self, by_target, by):
        """Remove a Point object of the Tier with chosen method. See `help(by)` for more information on the available methods. To change related parameters, use `parameters`."""

        if by not in _by.by_methods:
            raise Exception(
                f'Invalid by_method. Use help() to see valid methods.')

        point = self.get_point(by_target, by)

        if point == None:
            pass
        else:
            self.__points.remove(point)

        self._indexed = False
        self._reindex()
        self._indexed = True

    def remove_points(self, by_target, by):
        """Remove Point objects of the Tier with chosen method. See `help(by)` for more information on the available methods. To change related parameters, use `parameters`."""

        if by not in _by.by_methods:
            raise Exception(
                f'Invalid by_method. Use help() to see valid methods.')

        points = self.get_points(by_target, by)

        for point in points:
            if point == None:
                pass
            else:
                self.__points.remove(point)

        self._indexed = False
        self._reindex()
        self._indexed = True

    def interpolate_points(self, by_target, by, step=.01, strict=True):
        """Interpolate values between two Point objects (array-like), with given step (by default 0.01s). See `help(by)` for more information on the available methods. To change related parameters, use `parameters`."""

        if by not in _by.by_methods:
            raise Exception(
                'Invalid by_method. Use help() to see valid methods.')

        if len(by_target) != 2:
            raise Exception(
                '`interpolate_points()` takes exactly 2 targets.'
            )

        original_ignore_missing_point_parameter = parameters['points.ignore_missing_point']
        parameters['points.ignore_missing_point'] = False

        if by == _by.TIME and not strict:
            points = self.get_point(min(by_target), by, strict=strict, point_type='smaller'), self.get_point(max(by_target), by, strict=strict, point_type='larger')

        else:
            points = self.get_point(
                by_target[0], by), self.get_point(by_target[1], by)

        if points[0].time <= points[1].time:
            point1, point2 = points[0], points[1]
        else:
            point1, point2 = points[1], points[0]

        self.remove_points((point1.time, point2.time), _by.TIME)

        interpolated_point_times = np.arange(
            point1.time, point2.time, step)[1:]

        interpolated_points = [Point(time, value, superior_Tier=self) for time, value in zip(
            interpolated_point_times, np.interp(interpolated_point_times, [point1.time, point2.time], [point1.value, point2.value]))]

        self.add_points(interpolated_points)

        self._indexed = False
        self._reindex()
        self._indexed = True

        parameters['points.ignore_missing_point'] = original_ignore_missing_point_parameter

    def shift_point_to_time(self, target_time, by_target, by):
        """Shift Point of the Tier to chosen time with chosen method. To change related parameters, use `parameters`."""

        if by not in _by.by_methods:
            raise Exception(
                f'Invalid by_method. Use help() to see valid methods.')

        point = self.get_point(by_target, by)

        if point == None:
            pass
        else:
            self.remove_point(point, _by.Point())

            self._indexed = False
            point.time = target_time
            self._indexed = True

            self.add_point(point)

    def batch_raise_value(self, by_target, by, value):
        """Batch raise or lower value of points of the Tier. To change related parameters, use `parameters`."""

        points = self.get_points(by_target, by)

        for point in points:
            point.value += value

    def to_dataframe(self):
        """Convert the Tier to dataframe."""
        import pandas as pd

        df = pd.DataFrame()
        for point in self.__points:
            point_index, time, value = point.point_index, point.time, point.value

            df = pd.concat(
                [
                    df,
                    pd.DataFrame(
                        {'index': [point_index, ],
                         'time':[time, ],
                         'value':[value, ]
                         }
                    )
                ]
            )

        return df

    def write_to_file(self, file=''):
        """Write the Tier to file. By default saves to the original directory, if any. To change related parameters, use `parameters`."""

        if file == '':
            file = f'{self.original_dir}/{self.name}.{self.tier_type}'

        if os.path.isfile(file):
            if not parameters['write_to_file.replace']:
                if parameters['write_to_file.duplicate']:
                    original_dir = os.path.dirname(file)
                    file_name = f"{os.path.basename(file).split('.')[0]} {parameters['write_to_file.duplicated_name_extension']}"
                    file_extension = os.path.basename(file).split('.')[1]

                    file = f'{original_dir}/{file_name}.{file_extension}'
                else:
                    raise Exception(
                        f'{file} already exists. If you wish to replace it, set `replace` to True. If you wish to duplicate it, set `duplicate` to True.')

        with open(file, 'w') as f:
            f.writelines(
                [
                    'File type = "ooTextFile"\n',
                    f'Object class = "{self.tier_type}"\n',
                    '\n',
                    f'xmin = {self.start_time} \n',
                    f'xmax = {self.end_time} \n',
                    f'points: size = {len(self.__points)} \n'
                ]
            )

            for point in self.__points:
                f.writelines(
                    [
                        f'points [{point.point_index}]:\n'
                        f'\tnumber = {point.time} \n'
                        f'\tvalue = {point.value} \n'
                    ]
                )

    def _add_point_from_file(self, point):

        point = Point(point.time, point.value,
                      point.point_index, superior_Tier=self)

        if point.time < self.start_time or point.time > self.end_time:
            raise Exception('Trying to set a point outside of time linits.')

        time = point.time

        for this_point in self.__points:
            if this_point.time == time:
                raise Exception(
                    f'Point object with time {time} already exists.')

        self.__points.append(point)

    def _reindex(self):
        for idx, point in enumerate(self.__points):
            point.point_index = idx+1

    @property
    def points(self):
        """Points of this Tier."""

        return self.__points

    @property
    def start_time(self):
        """Start time of this Tier."""

        return self.__start_time

    @start_time.setter
    def start_time(self, start_time):
        if start_time < 0 or np.isnan(start_time):
            raise Exception('Invalid start_time.')
        self.__start_time = start_time

    @property
    def end_time(self):
        """End time of this Tier."""

        return self.__end_time

    @end_time.setter
    def end_time(self, end_time):
        if end_time < 0 or np.isnan(end_time):
            raise Exception('Invalid end_time.')
        self.__end_time = end_time

    @property
    def indexed(self):

        return self._indexed

    @property
    def original_dir(self):

        return self.__original_dir

    def __getitem__(self, item):
        return self.points[item]

    def __repr__(self):
        return f'{self.__class__.__name__}(name={self.name}, start_time={self.start_time}, end_time={self.end_time}, duration={self.end_time-self.start_time}, points={self.points})'


class PitchTier(Tier):
    """A PitchTier."""

    def __init__(self, start_time=0, end_time=0, name='', points=None, original_dir=''):
        super(PitchTier, self).__init__(start_time,
                                        end_time, name, points, original_dir)

        self.tier_type = 'PitchTier'


class DurationTier(Tier):
    """A DurationTier."""

    def __init__(self, start_time=0, end_time=0, name='', points=None, original_dir=''):
        super(DurationTier, self).__init__(
            start_time, end_time, name, points, original_dir)

        self.tier_type = 'DurationTier'


class Point(object):

    __slots__ = ['__time', '__value', '__point_index', '__superior_Tier']

    def __init__(self, time, value, point_index=None, superior_Tier=None):

        if value < 0 or np.isnan(value):
            raise Exception('Invalid value.')
        if time < 0 or np.isnan(time):
            raise Exception('Invalid time.')
        if point_index != None:
            if point_index < 1:
                raise Exception('Invalid point index.')

        self.__time = time
        self.__value = value
        self.__point_index = point_index
        self.__superior_Tier = superior_Tier

    @property
    def time(self):
        """Time of this Point object."""

        return self.__time

    @time.setter
    def time(self, time):

        if time < 0 or np.isnan(time):
            raise Exception('Invalid time.')
        self.__time = time

        if self.superior_Tier != None:
            self.superior_Tier._reindex()

    @property
    def value(self):
        """Value of this Point object."""

        return self.__value

    @value.setter
    def value(self, value):

        if value < 0 or np.isnan(value):
            raise Exception('Invalid pitch value.')
        self.__value = value

    @property
    def point_index(self):
        """Index of this Point object."""

        return self.__point_index

    @point_index.setter
    def point_index(self, point_index):

        if self.superior_Tier == None:
            if point_index < 1:
                raise Exception('Invalid point_index.')
            self.__point_index = point_index

        elif not self.superior_Tier.indexed:
            self.__point_index = point_index

        else:
            raise AttributeError('can`t set attribute')

    @property
    def superior_Tier(self):
        """The PitchTier to which this Point object belongs."""

        return self.__superior_Tier

    def __repr__(self):
        return f'{self.__class__.__name__}[{self.point_index}](time={self.time}, value={self.value})'
