import copy
import logging

import pandas as pd
from pandas import DataFrame, Series, Int64Index

from roughsets_base.roughset_si import RoughSetSI


class RoughSetDT(RoughSetSI):
    """Class RoughSet to model a decision table (DT).

    DT = f(X, A, y),

    where:
    X - objects of universe,
    A - attributes describing objects of X,
    y - a decision attribute related to X.

    """

    def __init__(self, X: DataFrame, y: Series = None, ind_index_name="IND_INDEX"):
        """Initialize object of class RoughSet

        Parameters
        ----------

        X: DataFrame
            Objects of universe of type: pandas DataFrame
        y: Series
            Decision set related to X or None if used simple SI
        ind_index_name: string, default 'IND_INDEX'
            Name of a special column to store index of discernibilty relation,
            computed by the function: get_indiscernibility_relations function.

        Note: X and y are computed as data structures with nominal values.

        References
        ----------
        pandas array: https://pandas.pydata.org/docs/reference/arrays.html
        """

        super().__init__(X, ind_index_name)

        self.default_class_attr = "target"

        if isinstance(y, list):
            y = pd.Series(y, name=self.default_class_attr)

        self.__assert_X_y(X, y)
        self.y = y

        if self.ind_rel_column_index_name in self.X.columns:
            raise ValueError(f"You can not use {self.ind_rel_column_index_name} as a column name.")

        self.ind_index_name = ind_index_name  # nazwa kolumny pomocniczej dla relacji nieodróżnialności

    def __assert_X_y(self, X, y):
        if not isinstance(y, Series):
            raise Exception("y must be a type of list or Pandas Series. See more: https://pandas.pydata.org/docs/reference/api/pandas.Series.html")

        if not isinstance(X, DataFrame):
            raise Exception("X must be a type of Pandas DataFrame. See more: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html")

        if not len(X.index) == len(y.index):
            raise Exception("Number of objects in X does not match number of decisions in y.")

    def concat_X_and_y(self, X, y) -> DataFrame:
        """Add y series as a column to X DataFrame"""
        Xy = pd.concat([X, y], axis=1)
        return Xy

    def get_Xy(self) -> DataFrame:
        """
        Get X and y as one DataFrame
        """
        return self.concat_X_and_y(self.X, self.y)

    @property
    def __column_name_y(self) -> str:
        return self.y.name

    def __get_empty_X(self, columns=None) -> DataFrame:
        """Get empty X"""
        if columns is None:
            columns = self.__column_names_X
        return DataFrame(columns=columns)

    def __get_empty_y(self) -> Series:
        """Get empty y"""

        column = self.__column_name_y
        return Series(data=[], name=column)

    def get_all_concepts(self):
        return self.y.drop_duplicates().reset_index(drop=True)

    def get_Xy_with_indiscernibility_relations_index(self, subset=None):

        X_IND, IND_OF_X = self.get_X_with_indiscernibility_relations_index(subset)

        # Add column with an IDrelation: X_IND -> y
        y_IND = pd.DataFrame(self.y.copy())
        y_IND[self.ind_index_name] = X_IND[self.ind_index_name]

        # Zliczenie ilości klas dla każdej relacji nieodróżnialności
        # i dodanie do IND_OF_X
        # Count classes for each indiscernibility_relation
        y_class_count = y_IND.drop_duplicates().groupby(
            [self.ind_index_name]
        )[self.y.name].count().reset_index(
            name="count"
        )["count"]

        # Add number of classes to each indiscernibility_relation
        IND_OF_X["y_class_count"] = y_class_count

        return X_IND, y_IND, IND_OF_X

    def get_approximation_indices(self, concepts=None, subset=None):
        """
        Get Pandas DataFrame indices which describe approximations boundaries.

        Parameters
        ----------

        concepts: list of decisions for which approximations boundaries must be evaluated.
            If None, computation will be done for all decisions.

        subset: column label or sequence of labels, optional
            Only consider certain columns for identifying duplicates,
            by default use all of the columns.

        Returns
        -------
        Tuple: positive_region_of_X, boundary_region_of_X, upper_approximation_of_X, negative_region_of_X


        """

        if concepts is None or concepts == [] or concepts == pd.Series.empty:
            concepts = self.get_all_concepts()

        if not isinstance(concepts, Series):
            concepts = pd.Series(concepts)

        # IND_OF_X_EXT - indiscernibilty relations (extended with columns: y_class_count and <self.ind_index_name>)
        X_IND, y_IND, IND_OF_X_EXT = self.get_Xy_with_indiscernibility_relations_index(
            subset=subset
        )

        # Get indexes of indiscernibilty relations, related to concepts
        IND_concept = y_IND[
            y_IND[self.y.name].isin(concepts)
        ][self.ind_index_name].drop_duplicates()

        # Get indexes of IND_OF_X_EXT which belong to concept
        IND_OF_X_by_concept = IND_OF_X_EXT[IND_OF_X_EXT[self.ind_index_name].isin(IND_concept)]

        # Get a lower approximation (if only one concept) or sum of lower approximations (if more than one concept)
        # (DataFrame's indexes of dataset X)
        ind_index_of_lower_approximation: Series = IND_OF_X_by_concept[
            IND_OF_X_by_concept["y_class_count"] == 1
        ][self.ind_index_name]
        lower_approximation_of_X: Int64Index = X_IND[
            X_IND[self.ind_index_name].isin(ind_index_of_lower_approximation)
        ].index

        # Get a boundary region (DataFrame's indexes of dataset X)
        ind_index_of_boundary_region: Series = IND_OF_X_by_concept[
            IND_OF_X_by_concept["y_class_count"] > 1
        ][self.ind_index_name]
        boundary_region_of_X: Int64Index = X_IND[
            X_IND[self.ind_index_name].isin(ind_index_of_boundary_region)
        ].index

        # Get a upper approximation (if only one concept) or sum of upper approximations (if more than one concept)
        # (DataFrame's indexes of dataset X)
        upper_approximation_of_X: Int64Index = lower_approximation_of_X.append(boundary_region_of_X).sort_values()

        # Get a negative region (DataFrame's indexes of dataset X)
        negative_region_of_X = self.X.index.delete(upper_approximation_of_X).sort_values()

        # Get a negative region (DataFrame's indexes of dataset X) (method 2)
        # IND_OF_X_negative_by_concept = IND_OF_X_EXT[
        #     ~IND_OF_X_EXT[self.ind_index_name].isin(IND_concept)
        # ]
        # ind_index_of_negative_region: Series = IND_OF_X_negative_by_concept[self.ind_index_name]
        # negative_region_of_X: Int64Index = X_IND[
        #     X_IND[self.ind_index_name].isin(ind_index_of_negative_region)
        # ].index

        return lower_approximation_of_X, boundary_region_of_X, upper_approximation_of_X, negative_region_of_X

    def get_approximation_objects(self, approximation_indices: Int64Index) -> (DataFrame, Series):
        """
        Get subset (defined by approximation_indices) of X and y objects
        """
        selection = self.y.index.isin(approximation_indices)
        return self.X[selection], self.y[selection]

import copy
import logging

import pandas as pd
from pandas import DataFrame, Series, Int64Index

from roughsets_base.roughset_si import RoughSetSI


class RoughSetDT(RoughSetSI):
    """Class RoughSet to model a decision table (DT).

    DT = f(X, A, y),

    where:
    X - objects of universe,
    A - attributes describing objects of X,
    y - a decision attribute related to X.

    """

    def __init__(self, X: DataFrame, y: Series = None, ind_index_name="IND_INDEX"):
        """Initialize object of class RoughSet

        Parameters
        ----------

        X: DataFrame
            Objects of universe of type: pandas DataFrame
        y: Series
            Decision set related to X or None if used simple SI
        ind_index_name: string, default 'IND_INDEX'
            Name of a special column to store index of discernibilty relation,
            computed by the function: get_indiscernibility_relations function.

        Note: X and y are computed as data structures with nominal values.

        References
        ----------
        pandas array: https://pandas.pydata.org/docs/reference/arrays.html
        """

        super().__init__(X, ind_index_name)

        self.default_class_attr = "target"

        if isinstance(y, list):
            y = pd.Series(y, name=self.default_class_attr)

        self.__assert_X_y(X, y)
        self.y = y

        if self.ind_rel_column_index_name in self.X.columns:
            raise ValueError(f"You can not use {self.ind_rel_column_index_name} as a column name.")

        self.ind_index_name = ind_index_name  # nazwa kolumny pomocniczej dla relacji nieodróżnialności

    def __assert_X_y(self, X, y):
        if not isinstance(y, Series):
            raise Exception("y must be a type of list or Pandas Series. See more: https://pandas.pydata.org/docs/reference/api/pandas.Series.html")

        if not isinstance(X, DataFrame):
            raise Exception("X must be a type of Pandas DataFrame. See more: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html")

        if not len(X.index) == len(y.index):
            raise Exception("Number of objects in X does not match number of decisions in y.")

    def concat_X_and_y(self, X, y) -> DataFrame:
        """Add y series as a column to X DataFrame"""
        Xy = pd.concat([X, y], axis=1)
        return Xy

    def get_Xy(self) -> DataFrame:
        """
        Get X and y as one DataFrame
        """
        return self.concat_X_and_y(self.X, self.y)

    @property
    def __column_name_y(self) -> str:
        return self.y.name

    def __get_empty_X(self, columns=None) -> DataFrame:
        """Get empty X"""
        if columns is None:
            columns = self.__column_names_X
        return DataFrame(columns=columns)

    def __get_empty_y(self) -> Series:
        """Get empty y"""

        column = self.__column_name_y
        return Series(data=[], name=column)

    def get_all_concepts(self):
        return self.y.drop_duplicates().reset_index(drop=True)

    def get_Xy_with_indiscernibility_relations_index(self, subset=None):

        X_IND, IND_OF_X = self.get_X_with_indiscernibility_relations_index(subset)

        # Add column with an IDrelation: X_IND -> y
        y_IND = pd.DataFrame(self.y.copy())
        y_IND[self.ind_index_name] = X_IND[self.ind_index_name]

        # Zliczenie ilości klas dla każdej relacji nieodróżnialności
        # i dodanie do IND_OF_X
        # Count classes for each indiscernibility_relation
        y_class_count = y_IND.drop_duplicates().groupby(
            [self.ind_index_name]
        )[self.y.name].count().reset_index(
            name="count"
        )["count"]

        # Add number of classes to each indiscernibility_relation
        IND_OF_X["y_class_count"] = y_class_count

        return X_IND, y_IND, IND_OF_X

    def get_approximation_indices(self, concepts=None, subset=None):
        """
        Get Pandas DataFrame indices which describe approximations boundaries.

        Parameters
        ----------

        concepts: list of decisions for which approximations boundaries must be evaluated.
            If None, computation will be done for all decisions.

        subset: column label or sequence of labels, optional
            Only consider certain columns for identifying duplicates,
            by default use all of the columns.

        Returns
        -------
        Tuple: positive_region_of_X, boundary_region_of_X, upper_approximation_of_X, negative_region_of_X


        """

        if concepts is None or concepts == [] or concepts == pd.Series.empty:
            concepts = self.get_all_concepts()

        if not isinstance(concepts, Series):
            concepts = pd.Series(concepts)

        # IND_OF_X_EXT - indiscernibilty relations (extended with columns: y_class_count and <self.ind_index_name>)
        X_IND, y_IND, IND_OF_X_EXT = self.get_Xy_with_indiscernibility_relations_index(
            subset=subset
        )

        # Get indexes of indiscernibilty relations, related to concepts
        IND_concept = y_IND[
            y_IND[self.y.name].isin(concepts)
        ][self.ind_index_name].drop_duplicates()

        # Get indexes of IND_OF_X_EXT which belong to concept
        IND_OF_X_by_concept = IND_OF_X_EXT[IND_OF_X_EXT[self.ind_index_name].isin(IND_concept)]

        # Get a lower approximation (if only one concept) or sum of lower approximations (if more than one concept)
        # (DataFrame's indexes of dataset X)
        ind_index_of_lower_approximation: Series = IND_OF_X_by_concept[
            IND_OF_X_by_concept["y_class_count"] == 1
        ][self.ind_index_name]
        lower_approximation_of_X: Int64Index = X_IND[
            X_IND[self.ind_index_name].isin(ind_index_of_lower_approximation)
        ].index

        # Get a boundary region (DataFrame's indexes of dataset X)
        ind_index_of_boundary_region: Series = IND_OF_X_by_concept[
            IND_OF_X_by_concept["y_class_count"] > 1
        ][self.ind_index_name]
        boundary_region_of_X: Int64Index = X_IND[
            X_IND[self.ind_index_name].isin(ind_index_of_boundary_region)
        ].index

        # Get a upper approximation (if only one concept) or sum of upper approximations (if more than one concept)
        # (DataFrame's indexes of dataset X)
        upper_approximation_of_X: Int64Index = lower_approximation_of_X.append(boundary_region_of_X).sort_values()

        # Get a negative region (DataFrame's indexes of dataset X)
        negative_region_of_X = self.X.index.delete(upper_approximation_of_X).sort_values()

        # Get a negative region (DataFrame's indexes of dataset X) (method 2)
        # IND_OF_X_negative_by_concept = IND_OF_X_EXT[
        #     ~IND_OF_X_EXT[self.ind_index_name].isin(IND_concept)
        # ]
        # ind_index_of_negative_region: Series = IND_OF_X_negative_by_concept[self.ind_index_name]
        # negative_region_of_X: Int64Index = X_IND[
        #     X_IND[self.ind_index_name].isin(ind_index_of_negative_region)
        # ].index

        return lower_approximation_of_X, boundary_region_of_X, upper_approximation_of_X, negative_region_of_X

    def get_approximation_objects(self, approximation_indices: Int64Index) -> (DataFrame, Series):
        """
        Get subset (defined by approximation_indices) of X and y objects
        """
        selection = self.y.index.isin(approximation_indices)
        return self.X[selection], self.y[selection]

Class RoughSet to model a decision table (DT).
 
     DT = f(X, A, y),
 
     where:
     X - objects of universe,
     A - attributes describing objects of X,
     y - a decision attribute related to X. Initialize object of class RoughSet
 
         Parameters
         ----------
 
         X: DataFrame
             Objects of universe of type: pandas DataFrame
         y: Series
             Decision set related to X or None if used simple SI
         ind_index_name: string, default 'IND_INDEX'
             Name of a special column to store index of discernibilty relation,
             computed by the function: get_indiscernibility_relations function.
 
         Note: X and y are computed as data structures with nominal values.
 
         References
         ----------
         pandas array: https://pandas.pydata.org/docs/reference/arrays.html # nazwa kolumny pomocniczej dla relacji nieodróżnialności Add y series as a column to X DataFrame Get X and y as one DataFrame Get empty X Get empty y # Add column with an IDrelation: X_IND -> y # Zliczenie ilości klas dla każdej relacji nieodróżnialności # i dodanie do IND_OF_X # Count classes for each indiscernibility_relation # Add number of classes to each indiscernibility_relation Get Pandas DataFrame indices which describe approximations boundaries.
 
         Parameters
         ----------
 
         concepts: list of decisions for which approximations boundaries must be evaluated.
             If None, computation will be done for all decisions.
 
         subset: column label or sequence of labels, optional
             Only consider certain columns for identifying duplicates,
             by default use all of the columns.
 
         Returns
         -------
         Tuple: positive_region_of_X, boundary_region_of_X, upper_approximation_of_X, negative_region_of_X # IND_OF_X_EXT - indiscernibilty relations (extended with columns: y_class_count and <self.ind_index_name>) # Get indexes of indiscernibilty relations, related to concepts # Get indexes of IND_OF_X_EXT which belong to concept # Get a lower approximation (if only one concept) or sum of lower approximations (if more than one concept) # (DataFrame's indexes of dataset X) # Get a boundary region (DataFrame's indexes of dataset X) # Get a upper approximation (if only one concept) or sum of upper approximations (if more than one concept) # (DataFrame's indexes of dataset X) # Get a negative region (DataFrame's indexes of dataset X) # Get a negative region (DataFrame's indexes of dataset X) (method 2) # IND_OF_X_negative_by_concept = IND_OF_X_EXT[ #     ~IND_OF_X_EXT[self.ind_index_name].isin(IND_concept) # ] # ind_index_of_negative_region: Series = IND_OF_X_negative_by_concept[self.ind_index_name] # negative_region_of_X: Int64Index = X_IND[ #     X_IND[self.ind_index_name].isin(ind_index_of_negative_region) # ].index Get subset (defined by approximation_indices) of X and y objects