import re
from datetime import timedelta
from pathlib import Path
from typing import Union

import numpy as np
try:
    import pandas as pd
except ImportError:
    from .pymesh.stubs import pandas as pd

from .time_series import TimeSeries
from .helpers.bc_check_provider import BCCheckProvider
from .._pytuflow_types import PathLike, TimeLike
from ..results import ResultTypeError


class BCTablesCheck(TimeSeries):
    """Class for reading 1D and 2D BC Table Check files.
    These are file that end with :code:`_bc_tables_check.csv`, found in the 1D and 2D check folders.

    Parameters
    ----------
    fpath : PathLike
        Path to the BC Table Check file.

    Raises
    ------
    ResultTypeError
        Raises :class:`pytuflow.results.ResultTypeError` if the file does not look like a ``BCTablesCheck`` file.

    Examples
    --------
    Load a 1D hydraulic tables check file:

    >>> from pytuflow import BCTablesCheck
    >>> bndry = BCTablesCheck('path/to/_bc_tables_check.csv')

    Check the boundary types in the check file:

    >>> bndry.data_types()
    ['QT', 'SA', 'HQ']

    Check the IDs for the flow boundaries (i.e. :code:`QT` and :code:`SA` types):

    >>> bndry.ids('flow')
    ['FC01', 'Loc_001', 'Loc_002', 'Loc_003', 'Loc_004']

    Get time-series data for boundary :code:`FC01`:

    >>> bndry.time_series('FC01', 'QT')
        FC01/time  FC01/QT
    0       0.000     0.00
    1       0.083     0.84
    2       0.167     3.31
    3       0.250     4.60
    4       0.333     7.03
    ...       ...      ...
    36      3.000     2.18
    37      3.083     1.96
    38      3.167     1.77
    39      3.250     1.60
    40      3.333     1.45
    """

    DOMAIN_TYPES = {'bctable': ['bctable']}
    GEOMETRY_TYPES = {}
    ATTRIBUTE_TYPES = {'qt': ['qt'], 'hq': ['hq'], 'qh': ['qh'], 'sa': ['sa'], 'rf': ['rf'], 'ht': ['ht']}
    ID_COLUMNS = ['id', 'uid']

    def __init__(self, fpath: PathLike):
        super().__init__(fpath)

        #: Path: The path to the source output file.
        self.fpath = Path(fpath)
        #: Path: Path to the parent TCF file
        self.tcf = Path()
        #: BCCheckProvider: BC Check Provider class
        self.provider = BCCheckProvider(self.fpath)
        #: pd.DataFrame: DataFrame with all the data combinations
        self.objs = pd.DataFrame(columns=['id', 'uid', 'type', 'data_type', 'domain'])

        if not self.fpath.exists():
            raise FileNotFoundError(f'File does not exist: {fpath}')

        if not self._looks_like_this(self.fpath):
            raise ResultTypeError(f'File does not look like a {self.__class__.__name__} file: {fpath}')

        if self._looks_empty(self.fpath):
            raise EOFError(f'File is empty or incomplete: {fpath}')

        self._load()

    @staticmethod
    def _looks_like_this(fpath: Path) -> bool:
        # docstring inherited
        # noinspection PyBroadException
        try:
            if not re.findall(r'_[12]d_bc_tables_check$', fpath.stem):
                return False
            with fpath.open() as f:
                line = f.readline()
                if not re.findall(r'^"?Generated by', line):
                    return False
        except Exception:
            return False
        return True

    @staticmethod
    def _looks_empty(fpath: Path) -> bool:
        # docstring inherited
        # noinspection PyBroadException
        try:
            with fpath.open() as f:
                for _ in range(3):
                    line = f.readline()
                if not re.findall(r'^BC\d{6}', line):
                    return True
        except Exception:
            return True
        return False

    def times(self, filter_by: str = None, fmt: str = 'relative') -> list[TimeLike]:
        """Returns a list of unique times in the BC Tables Check file for the given context.

        The context is an optional input that can be used to filter the return further. Valid contexts for this
        class are:

        * :code:`None` - no filtering, returns all the times in the file.

        Boundary Type

        * :code:`[type]` - returns all the times for a given boundary type (e.g. :code:`QT`)

        Data Type

        * :code:`[data_type]` - returns all the times for a given data type (e.g. :code:`flow`)

        ID

        * :code:`[id]` - returns all the times for a given ID (e.g. :code:`FC01`)

        Combine contexts:

        * :code:`[context1]/[context2] ...`: Combine multiple contexts to filter the times further
          (use :code:`/` delim).

        Parameters
        ----------
        filter_by : str, optional
            Context to filter the times.
        fmt : str, optional
            Format of the times. Can be :code:`relative` or :code:`absolute`. Default is :code:`relative`.

        Returns
        -------
        list[TimeLike]
            List of unique times in the BC Tables Check file.

        Examples
        --------
        Get the times for boundary :code:`FC01`:

        >>> bndry.times('FC01')
        [0.0, 0.08, 0.17 ... 3.17, 3.25, 3.33]
        """
        df, _ = self._filter(filter_by)
        times = []
        if df.empty:
            return times

        times_ = []
        for uid in df.uid.unique():
            bndry = self.provider.database[uid]
            if bndry.index_name != 'Time':
                continue
            times_.append(bndry.values[:, 0])

        if times_:
            comb = np.concatenate(times_)
            tol = 0.0001
            unique_inds = np.unique(np.round(comb / tol) * tol, return_index=True)[1]
            a = comb[unique_inds]
            times = np.sort(a).tolist()

        if fmt == 'absolute':
            times = [self.reference_time + timedelta(hours=x) for x in times]
        return times

    def ids(self, filter_by: str = None, internal_id: bool = False) -> list[str]:
        """Returns all the available IDs for the given context.

        The context is an optional input that can be used to filter the return further. Valid contexts for this
        class are:

        * :code:`None` - no filtering, returns all the IDs in the file.

        Boundary Type

        * :code:`[type]` - returns all the times for a given boundary type (e.g. :code:`QT`)

        ID

        * :code:`[id]` - returns all the times for a given ID (e.g. :code:`FC01`)

        Data Type

        * :code:`[data_type]` - returns all the times for a given data type (e.g. :code:`flow`)

        Combine contexts:

        * :code:`[context1]/[context2] ...`: Combine multiple contexts to filter the times further
          (use :code:`/` delim).

        Parameters
        ----------
        filter_by : str, optional
            Context to filter the IDs.
        internal_id : bool, optional
            Return the internal ID instead of the name e.g. :code:`BC000001`. Default is :code:`False`.

        Returns
        -------
        list[str]
            List of available IDs.

        Examples
        --------
        Get all the boundary types in the model:

        >>> bndry = BCTablesCheck('path/to/_bc_tables_check.csv')
        >>> bndry.data_types()
        ['QT', 'SA', 'HQ']

        Get all flow boundary types:

        >>> bndry.ids('flow')
        ['FC01', 'FC04']

        Get the boundary type for :code:`FC01`:

        >>> bndry.ids('FC01')
        ['QT']
        """
        df, _ = self._filter(filter_by)
        if internal_id:
            return df.uid.unique().tolist()
        return df.id.unique().tolist()

    def data_types(self, filter_by: str = None, bndry_type: bool = True) -> list[str]:
        """Returns all the available data types for the given context.

        The context is an optional input that can be used to filter the return further. Valid contexts for this
        class are:

        * :code:`None` - no filtering, returns all the data types in the file.

        Boundary Type

        * :code:`[type]` - returns all the times for a given boundary type (e.g. :code:`QT`)

        Data Type

        * :code:`[data_type]` - returns all the times for a given data type (e.g. :code:`flow`)

        Combine contexts:

        * :code:`[context1]/[context2] ...`: Combine multiple contexts to filter the times further
          (use :code:`/` delim).

        Parameters
        ----------
        filter_by : str, optional
            Context to filter the IDs.
        bndry_type : bool, optional
            Return the boundary type instead of the data type. e.g. return :code:`QT` rather than :code:`flow`.
            Default is :code:`True`.

        Returns
        -------
        list[str]
            List of available IDs.

        Examples
        --------
        Get all the :code:`QT` boundary IDs:

        >>> bndry = BCTablesCheck('path/to/_bc_tables_check.csv')
        >>> bndry.ids('QT')
        ['FC01']

        Get all the inflow IDs (:code:`QT` and :code:`SA` types):

        >>> bndry.ids('flow')
        ['FC01', 'FC04']
        """
        for filt in ['timeseries', 'point', 'line', 'polygon']:
            if filter_by and filt in filter_by:
                filter_by = filter_by.replace(filt, '')
                if not filter_by:
                    filter_by = None
        df, _ = self._filter(filter_by)
        if bndry_type:
            return df.type.unique().tolist()
        return df.data_type.unique().tolist()

    def maximum(self, locations: str | list[str] | None, data_types: str | list[str] | None,
                time_fmt: str = 'relative') -> pd.DataFrame:
        """Returns the maximum values for the given locations and data types. Only time-series data
        is supported (i.e. data with a time index, :code:`QH` boundaries are therefore not supporteD).

        Parameters
        ----------
        locations : str | list[str]
            The locations to extract the maximum values for.
        data_types : str | list[str]
            The data types to extract the maximum values for. Can use either data type (e.g. :code:`flow`) or boundary
            type (e.g. :code:`QT`)
        time_fmt : str, optional
            The format for the time values. Can be :code:`relative` or :code:`absolute`. Default is :code:`relative`.

        Returns
        -------
        pd.DataFrame
            DataFrame with the maximum values for the given locations and data types.

        Examples
        --------
        Get the maximum values for all the flow boundaries:

        >>> bndry = BCTablesCheck('path/to/_bc_tables_check.csv')
        >>> bndry.maximum(None, 'flow')
                 QT/max  QT/tmax  SA/max  SA/tmax
        FC01      92.24    0.917     NaN      NaN
        Loc_002     NaN      NaN   0.814     0.75
        Loc_001     NaN      NaN   1.628     0.75
        Loc_004     NaN      NaN   1.221     0.75
        Loc_003     NaN      NaN   1.221     0.75
        """
        df = self.time_series(locations, data_types)
        if df.empty:
            return pd.DataFrame()

        mx = pd.DataFrame()
        for i in range(0, len(df.columns), 2):
            ind_col, name = df.columns[i].split('/')
            typ = df.columns[i+1].split('/')[0]
            if ind_col.lower() != 'time':
                continue
            tmax = f'{typ}/tmax'
            max_ = f'{typ}/max'
            df1 = df.iloc[:,i:i+2].set_index(df.columns[i])
            if time_fmt == 'absolute':
                df1.index = [self.reference_time + timedelta(hours=x) for x in df1.index]
            df1 = df1.loc[df1.idxmax(), :]
            df1 = df1.reset_index().rename(index=lambda x: name, columns={df.columns[i]: tmax, df.columns[i+1]: max_})
            mx = pd.concat([mx, df1[[max_, tmax]]], axis=0) if not mx.empty else df1[[max_, tmax]]

        return mx

    def time_series(self, locations: str | list[str] | None, data_types: str | list[str] | None,
                    *args, **kwargs) -> pd.DataFrame:
        """Returns time-series data for the given locations and data types.

        Because boundary data is not guaranteed to share a common index, the return DataFrame will contain
        columns for both the indexes and values for the requested time-series data.

        Parameters
        ----------
        locations : str | list[str]
            The locations to extract the time series data for.
        data_types : str | list[str]
            The data types to extract the time series data for. Can use either data type (e.g. :code:`flow`) or boundary
            type (e.g. :code:`QT`)

        Returns
        -------
        pd.DataFrame
            Data frame with the time series data.

        Examples
        --------
        Get the time-series for boundary :code:`FC01`:

        >>> bndry.time_series('FC01', None)
            FC01/time  FC01/QT
        0       0.000     0.00
        1       0.083     0.84
        2       0.167     3.31
        3       0.250     4.60
        4       0.333     7.03
        ...       ...      ...
        36      3.000     2.18
        37      3.083     1.96
        38      3.167     1.77
        39      3.250     1.60
        40      3.333     1.45
        """
        locations, data_types = self._loc_data_types_to_list(locations, data_types)
        context = '/'.join(locations + data_types)
        ctx, _ = self._filter(context)
        if ctx.empty:
            return pd.DataFrame()

        df = pd.DataFrame()
        for typ in ctx.type.unique():
            for id1 in ctx.id.unique():
                uid = self.provider.name2bcid(id1)
                bndry = self.provider.database.get(uid)
                if not bndry or bndry.type != typ:
                    continue
                ind_col = f'{bndry.index_name.lower()}/{id1}'
                val_col = f'{typ}/{id1}'
                df1 = pd.DataFrame(bndry.values, columns=[ind_col, val_col])
                df = pd.concat([df, df1], axis=1) if not df.empty else df1

        return df

    def section(self, locations: Union[str, list[str]], data_types: Union[str, list[str]],
                time: TimeLike = -1, *args, **kwargs) -> pd.DataFrame:
        """Not supported for ``BCTablesCheck`` results. Raises a :code:`NotImplementedError`."""
        raise NotImplementedError(f'{__class__.__name__} does not support section plotting.')

    def curtain(self, locations: Union[str, list[str]], data_types: Union[str, list[str]],
                time: TimeLike) -> pd.DataFrame:
        """Not supported for ``BCTablesCheck`` results. Raises a :code:`NotImplementedError`."""
        raise NotImplementedError(f'{__class__.__name__} does not support curtain plotting.')

    def profile(self, locations: Union[str, list[str]], data_types: Union[str, list[str]],
                time: TimeLike, **kwargs) -> pd.DataFrame:
        """Not supported for ``BCTablesCheck`` results. Raises a :code:`NotImplementedError`."""
        raise NotImplementedError(f'{__class__.__name__} does not support vertical profile plotting.')

    def _load(self):
        self.name = re.sub(r'_[12]d_bc_tables_check', '', self.fpath.stem)
        self.provider.load()
        self.tcf = self.provider.tcf
        self._load_objs()

    def _overview_dataframe(self) -> pd.DataFrame:
        return self.objs.copy()

    def _load_objs(self):
        d = {'id': [], 'type': [], 'uid': [], 'data_type': [], 'domain': []}
        for id_, bndry in self.provider.database.items():
            d['id'].append(bndry.name)
            d['type'].append(bndry.type)
            d['uid'].append(bndry.id)
            d['data_type'].append(bndry.data_type)
            d['domain'].append('bctable')
        self.objs = pd.DataFrame(d)
