import logging
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Union

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

from .tabular_output import TabularOutput
from .helpers.hyd_tables_cross_section_provider import HydTablesCrossSectionProvider
from .helpers.hyd_tables_channel_provider import HydTablesChannelProvider
from .._pytuflow_types import PathLike, TimeLike
from ..results import ResultTypeError


logger = logging.getLogger('pytuflow')


class HydTablesCheck(TabularOutput):
    """Class for reading the TUFLOW check file for 1D hydraulic tables.
    These are file that end with :code:`_1d_ta_tables_check.csv`, found in the 1D check folder.

    Parameters
    ----------
    fpath : :class:`PathLike <pytuflow.pytuflow_types.PathLike>`
        The path to the 1D hydraulic tables check file.

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

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

    >>> from pytuflow import HydTablesCheck
    >>> hyd_tables = HydTablesCheck('path/to/1d_ta_tables_check.csv')
    """

    DOMAIN_TYPES = {'hydraulictable': ['hydraulictable']}
    GEOMETRY_TYPES = {
        'xs': ['xs', 'cross-section', 'cross_section', 'cross section'],
        'processed': ['processed', 'proc'],
        'channel': ['channel', 'chan'],
    }
    ATTRIBUTE_TYPES = {'xz': ['xz'], 'hw': ['hw'], 'cs': ['cs'], 'bg': ['bg'], 'lc': ['lc']}
    ID_COLUMNS = ['id', 'uid']

    def __init__(self, fpath: PathLike) -> None:
        super().__init__(fpath)

        self.reference_time = datetime(2000, 1, 1, tzinfo=timezone.utc)
        self.units = ''
        #: Path: The path to the source output file.
        self.fpath = Path(fpath)
        #: Path: Path to the parent TCF file
        self.tcf = Path()
        #: :class:`HydTablesCrossSectionProvider <pytuflow.outputs.helpers.hyd_tables_cross_sections.HydTablesCrossSectionProvider>`: Cross-section data provider
        self._cross_sections = HydTablesCrossSectionProvider()
        #: :class:`HydTablesChannelProvider <pytuflow.outputs.helpers.hyd_tables_channel_provider.HydTablesChannelProvider>`: Channel data provider
        self._channels = HydTablesChannelProvider()
        #: pd.DataFrame: DataFrame with all the data combinations
        self._objs = pd.DataFrame(columns=['id', 'uid', 'type', 'data_type', 'geometry', 'domain'])
        #: int: Number of cross-sections
        self.cross_section_count = 0
        #: int: Number of channels
        self.channel_count = 0

        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'_1d_ta_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'^"?Section XS\d{5}', line):
                    return True
        except Exception:
            return True
        return False

    def times(self, filter_by: str = None, fmt: str = 'relative') -> list[TimeLike]:
        """HydraulicTableCheck1D results are static and will not return any times."""
        return []  # data is static - no times exist

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

        The ``filter_by`` argument can be used to add a filter to the returned IDs. Available filters for the
        ``HydTablesCheck`` class are:

        * :code:`None` - returns all IDs

        Process step filters:

        * :code:`xs` - returns the IDs that have raw cross-section data
        * :code:`processed` - returns the IDs that have processed cross-section data
        * :code:`channel` - returns the IDs for the processed channels

        Type filters:

        * :code:`[type]` - returns the IDs that match the given cross-section type (e.g. "XZ" or "HW")

        Data type filters:

        * :code:`[data type]` - returns the IDs that match the given data type (e.g. "Storage Width")

        Combine filters:

        * [filter1]/[filter2] ...: (use ``/`` to delim).

        Parameters
        ----------
        filter_by : str, optional
            The string to filter the IDs by.

        Returns
        -------
        list[str]
            The available IDs.

        Examples
        --------
        Return all IDs for cross-section of type :code:`XZ`:

        >>> hyd_tables = HydTablesCheck('/path/to/1d_ta_tables_check.csv')
        >>> hyd_tables.ids('xz')
        ['1d_xs_M14_C99', '1d_xs_M14_C100', '1d_xs_M14_C101'  ...  '1d_xs_M14_C102', '1d_xs_M14_C103', '1d_xs_M14_C104']

        Return the name of the processed channels using the tabular data:

        >>> hyd_tables.ids('channel')
        ['RD_weir', 'FC01.39', 'FC01.38'  ...  FC01.37', 'FC01.36', 'FC01.34']
        """
        df, _ = self._filter(filter_by)
        return df.id.unique().tolist()

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

        The argument ``filter_by`` can be used to add a filter to the returned IDs. Available filters for the
        ``HydTablesCheck`` class are:

        * :code:`None` - returns all data types

        Process step filters:

        * :code:`xs` - returns the available data types for the raw cross-sections
        * :code:`processed` - returns the data types available for the processed cross-sections
        * :code:`channel` - returns the data types available for the processed channels

        Type filters:

        * :code:`[type]` - returns the available data types that match the given cross-section type (e.g. "XZ" or "HW")

        Data type filters:

        * :code:`[id]` - returns the available data types for the given ID

        Combine filters:

        * [filter1]/[filter2] ...: (use ``/`` to delim).

        Parameters
        ----------
        filter_by : str, optional
            The string to filter the data types by.

        Returns
        -------
        list[str]
            The available data types.

        Examples
        --------
        Return all data types for the processed cross-sections:

        >>> hyd_tables = HydTablesCheck('/path/to/1d_ta_tables_check.csv')
        >>> hyd_tables.data_types('processed')
        ['depth', 'width', 'effective width', 'effective area',
        'effective wetted perimeter', 'radius', 'vertex resistance factor', 'k ']

        Returns all data types for a given channel ID:

        >>> hyd_tables.data_types('FC01.39')
        ['depth', 'storage width', 'flow width', 'area', 'wetted perimeter',
        'radius', 'vertex resistance factor', 'k ']
        """
        for filt in ['section', 'line']:
            if filter_by is not None and filt in filter_by:
                filter_by = filter_by.replace(filt, '')
                if not filter_by:
                    filter_by = None
        df, _ = self._filter(filter_by)
        return df.data_type.unique().tolist()

    def section(self, locations: Union[str, list[str]], data_types: Union[str, list[str]],
                time: TimeLike = -1, *args, **kwargs) -> pd.DataFrame:
        """Returns section data for the given locations and data types from the 1D tables check file.

        This method supports returning multiple locations and data types. As a consequence,
        the returned DataFrame will use pd.MultiIndex columns since the returned DataFrame is not
        guaranteed to share a common index. The column index will be made up of 2 levels: the first level
        is the location name and what part of the processing stage the data came from (e.g. xs, processed, channel).
        The second level will be the normal column names, such as :code:`elevation` and :code:`flow width`.
        The first column of any grouped level 1 index, will be the index column.

        E.g. if you are getting elevation and manning n data for cross-section :code:`XS001` the level 1 will all be
        :code:`XS001/xs` and the level 2 indexes will be :code:`distance`, :code:`elevation`, :code:`manning n`.
        The first column (:code:`distance`) should be treated as the index column for this group.

        Parameters
        ----------
        locations : str or list[str]
            The locations to return data for. This can be a single location or a list of locations.
        data_types : str or list[str]
            The data types to return. This can be a single data type or a list of data types.
        time : TimeLike, optional
            The time to return data for. This is not used for this class and can be ignored.

        Returns
        -------
        pd.DataFrame
            The section data for the given locations and data types.

        Examples
        --------
        Return the processed :code:`flow width` for channel :code:`FC01.39`:

        >>> hyd_tables = HydTablesCheck('/path/to/1d_ta_tables_check.csv')
        >>> hyd_tables.section('FC01.39', 'flow width')
           FC01.39/channel
                 elevation flow width
        0           45.500      0.002
        1           45.517      0.929
        2           45.717      3.781
        3           45.917      4.414
        4           46.117      5.069
        ...           ...         ...
        30          51.317     23.462
        31          51.517     23.655
        32          51.717     23.848
        33          51.917     24.172
        34          51.926     24.194
        """
        # figure out locations and data types
        locations, data_types = self._figure_out_loc_and_data_types(locations, data_types)
        dtypes = [self._get_standard_data_type_name(x) for x in data_types]

        # get more filters on the inputs - e.g. what stage of processing they are from
        ctx = '/'.join(locations + data_types)
        df, _ = self._filter(ctx)

        df1 = pd.DataFrame()
        for loc in locations:
            for proc_type in ['xs', 'processed', 'channel']:
                if df[(df.id == loc) & (df.geometry == proc_type)].empty:
                    continue
                if proc_type in ['xs', 'processed']:
                    loc1 = self._cross_sections.name2id(loc) if loc not in self._cross_sections.database else loc
                    if proc_type == 'xs':
                        df_ = self._cross_sections.database[loc1].df_xs
                    else:
                        df_ = self._cross_sections.database[loc1].df_proc[data_types]
                else:
                    df_ = self._channels.database[loc][data_types]

                dtypes1 = [x for x in dtypes if x in df_.columns]
                dtypes2 = [data_types[i] for i, x in enumerate(dtypes) if x in df_.columns]
                df_ = df_[dtypes1]
                df_.columns = dtypes2
                if df_ is None or df_.empty:
                    continue
                df_.reset_index(inplace=True)
                df_.columns = pd.MultiIndex.from_product([[f'{loc}/{proc_type}'], df_.columns])
                df1 = pd.concat([df1, df_], axis=1) if not df1.empty else df_

        return df1

    def time_series(self, locations: str | list[str] | None, data_types: str | list[str] | None,
                    time_fmt: str = 'relative', *args, **kwargs) -> pd.DataFrame:
        """Not supported for ``HydTablesCheck`` results. Raises a :code:`NotImplementedError`."""
        raise NotImplementedError(f'{__class__.__name__} does not support time-series plotting.')

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

    def _load(self):
        self.name = re.sub(r"_1d_ta_tables_check$", '', self.fpath.stem)
        with self.fpath.open() as f:
            line = f.readline()
            s = line.split('"')
            i = 1 if 'generated by' not in s[1].lower() else 3
            self.tcf = s[i].strip()
            self.tcf = Path(self.tcf)
            while not self._cross_sections.finished:
                self._cross_sections.read_next(f)
            self.cross_section_count = len(self._cross_sections.database)
            while not self._channels.finished:
                self._channels.read_next(f)
            self.channel_count = len(self._channels.database)
        self._load_objs()

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

    def _load_objs(self):
        def add_xs_prop(d_, xs_):
            d_['id'].append(xs_.name)
            d_['uid'].append(xs_.id)
            d_['type'].append(xs_.type)

        d = {'id': [], 'type': [], 'uid': [], 'data_type': [], 'geometry': [], 'domain': []}

        # cross-sections
        for id_, xs in self._cross_sections.database.items():
            if xs.has_xs:
                for col in xs.df_xs.columns:
                    if col in ['point', 'message']:
                        continue
                    add_xs_prop(d, xs)
                    d['data_type'].append(self._get_standard_data_type_name(col))
                    d['geometry'].append('xs')
                    d['domain'].append('hydraulictable')
            for col in xs.df_proc.columns:
                if col in ['point', 'message']:
                    continue
                add_xs_prop(d, xs)
                d['data_type'].append(self._get_standard_data_type_name(col))
                d['geometry'].append('processed')
                d['domain'].append('hydraulictable')

        # channels
        for id_, ch in self._channels.database.items():
            for col in ch.columns:
                if col in ['point', 'message']:
                    continue
                d['id'].append(id_)
                d['uid'].append('')
                d['type'].append('')
                d['data_type'].append(self._get_standard_data_type_name(col))
                d['geometry'].append('channel')
                d['domain'].append('hydraulictable')

        self._objs = pd.DataFrame(d)
