"""Parsing response from InfluxDB to FluxStructures or DataFrame."""

import base64
import codecs
import csv as csv_parser
import warnings
from enum import Enum
from typing import List

from influxdb_client.client.flux_table import FluxTable, FluxColumn, FluxRecord, TableList
from influxdb_client.client.util.date_utils import get_date_helper
from influxdb_client.rest import _UTF_8_encoding

ANNOTATION_DEFAULT = "#default"
ANNOTATION_GROUP = "#group"
ANNOTATION_DATATYPE = "#datatype"
ANNOTATIONS = [ANNOTATION_DEFAULT, ANNOTATION_GROUP, ANNOTATION_DATATYPE]


class FluxQueryException(Exception):
    """The exception from InfluxDB."""

    def __init__(self, message, reference) -> None:
        """Initialize defaults."""
        self.message = message
        self.reference = reference


class FluxCsvParserException(Exception):
    """The exception for not parsable data."""

    pass


class FluxSerializationMode(Enum):
    """The type how we want to serialize data."""

    tables = 1
    stream = 2
    dataFrame = 3


class FluxResponseMetadataMode(Enum):
    """The configuration for expected amount of metadata response from InfluxDB."""

    full = 1
    # useful for Invokable scripts
    only_names = 2


class _FluxCsvParserMetadata(object):
    def __init__(self):
        self.table_index = 0
        self.table_id = -1
        self.start_new_table = False
        self.table = None
        self.groups = []
        self.parsing_state_error = False


class FluxCsvParser(object):
    """Parse to processing response from InfluxDB to FluxStructures or DataFrame."""

    def __init__(self, response, serialization_mode: FluxSerializationMode,
                 data_frame_index: List[str] = None, query_options=None,
                 response_metadata_mode: FluxResponseMetadataMode = FluxResponseMetadataMode.full,
                 use_extension_dtypes=False) -> None:
        """
        Initialize defaults.

        :param response: HTTP response from a HTTP client.
                         Acceptable types: `urllib3.response.HTTPResponse`, `aiohttp.client_reqrep.ClientResponse`.
        """
        self._response = response
        self.tables = TableList()
        self._serialization_mode = serialization_mode
        self._response_metadata_mode = response_metadata_mode
        self._use_extension_dtypes = use_extension_dtypes
        self._data_frame_index = data_frame_index
        self._data_frame_values = []
        self._profilers = query_options.profilers if query_options is not None else None
        self._profiler_callback = query_options.profiler_callback if query_options is not None else None
        self._async_mode = True if 'ClientResponse' in type(response).__name__ else False

    def _close(self):
        self._response.close()

    def __enter__(self):
        """Initialize CSV reader."""
        # response can be exhausted by logger, so we have to use data that has already been read
        if hasattr(self._response, 'closed') and self._response.closed:
            from io import StringIO
            self._reader = csv_parser.reader(StringIO(self._response.data.decode(_UTF_8_encoding)))
        else:
            self._reader = csv_parser.reader(codecs.iterdecode(self._response, _UTF_8_encoding))
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        """Close HTTP response."""
        self._close()

    async def __aenter__(self) -> 'FluxCsvParser':
        """Initialize CSV reader."""
        from aiocsv import AsyncReader
        self._reader = AsyncReader(_StreamReaderToWithAsyncRead(self._response.content))

        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
        """Shutdown the client."""
        self.__exit__(exc_type, exc_val, exc_tb)

    def generator(self):
        """Return Python generator."""
        with self as parser:
            for val in parser._parse_flux_response():
                yield val

    def generator_async(self):
        """Return Python async-generator."""
        return self._parse_flux_response_async()

    def _parse_flux_response(self):
        metadata = _FluxCsvParserMetadata()

        for csv in self._reader:
            for val in self._parse_flux_response_row(metadata, csv):
                yield val

        # Return latest DataFrame
        if (self._serialization_mode is FluxSerializationMode.dataFrame) & hasattr(self, '_data_frame'):
            df = self._prepare_data_frame()
            if not self._is_profiler_table(metadata.table):
                yield df

    async def _parse_flux_response_async(self):
        metadata = _FluxCsvParserMetadata()

        try:
            async for csv in self._reader:
                for val in self._parse_flux_response_row(metadata, csv):
                    yield val

            # Return latest DataFrame
            if (self._serialization_mode is FluxSerializationMode.dataFrame) & hasattr(self, '_data_frame'):
                df = self._prepare_data_frame()
                if not self._is_profiler_table(metadata.table):
                    yield df
        except BaseException as e:
            e_type = type(e).__name__
            if "CancelledError" in e_type or "TimeoutError" in e_type:
                e.add_note("Stream cancelled during read.  Recommended: Check Influxdb client `timeout` setting.")
            raise
        finally:
            self._close()

    def _parse_flux_response_row(self, metadata, csv):
        if len(csv) < 1:
            # Skip empty line in results (new line is used as a delimiter between tables or table and error)
            pass

        elif "error" == csv[1] and "reference" == csv[2]:
            metadata.parsing_state_error = True

        else:
            # Throw  InfluxException with error response
            if metadata.parsing_state_error:
                error = csv[1]
                reference_value = csv[2]
                raise FluxQueryException(error, reference_value)

            token = csv[0]
            # start new table
            if (token in ANNOTATIONS and not metadata.start_new_table) or \
                    (self._response_metadata_mode is FluxResponseMetadataMode.only_names and not metadata.table):

                # Return already parsed DataFrame
                if (self._serialization_mode is FluxSerializationMode.dataFrame) & hasattr(self, '_data_frame'):
                    df = self._prepare_data_frame()
                    if not self._is_profiler_table(metadata.table):
                        yield df

                metadata.start_new_table = True
                metadata.table = FluxTable()
                self._insert_table(metadata.table, metadata.table_index)
                metadata.table_index = metadata.table_index + 1
                metadata.table_id = -1
            elif metadata.table is None:
                raise FluxCsvParserException("Unable to parse CSV response. FluxTable definition was not found.")

            #  # datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,double,string,string,string
            if ANNOTATION_DATATYPE == token:
                self.add_data_types(metadata.table, csv)

            elif ANNOTATION_GROUP == token:
                metadata.groups = csv

            elif ANNOTATION_DEFAULT == token:
                self.add_default_empty_values(metadata.table, csv)

            else:
                # parse column names
                if metadata.start_new_table:
                    # Invokable scripts doesn't supports dialect => all columns are string
                    if not metadata.table.columns and \
                            self._response_metadata_mode is FluxResponseMetadataMode.only_names:
                        self.add_data_types(metadata.table, list(map(lambda column: 'string', csv)))
                        metadata.groups = list(map(lambda column: 'false', csv))
                    self.add_groups(metadata.table, metadata.groups)
                    self.add_column_names_and_tags(metadata.table, csv)
                    metadata.start_new_table = False
                    # Create DataFrame with default values
                    if self._serialization_mode is FluxSerializationMode.dataFrame:
                        from ..extras import pd
                        labels = list(map(lambda it: it.label, metadata.table.columns))
                        self._data_frame = pd.DataFrame(data=[], columns=labels, index=None)
                        pass
                else:

                    # to int conversions todo
                    current_id = int(csv[2])
                    if metadata.table_id == -1:
                        metadata.table_id = current_id

                    if metadata.table_id != current_id:
                        # create    new        table       with previous column headers settings
                        flux_columns = metadata.table.columns
                        metadata.table = FluxTable()
                        metadata.table.columns.extend(flux_columns)
                        self._insert_table(metadata.table, metadata.table_index)
                        metadata.table_index = metadata.table_index + 1
                        metadata.table_id = current_id

                    flux_record = self.parse_record(metadata.table_index - 1, metadata.table, csv)

                    if self._is_profiler_record(flux_record):
                        self._print_profiler_info(flux_record)
                    else:
                        if self._serialization_mode is FluxSerializationMode.tables:
                            self.tables[metadata.table_index - 1].records.append(flux_record)

                        if self._serialization_mode is FluxSerializationMode.stream:
                            yield flux_record

                        if self._serialization_mode is FluxSerializationMode.dataFrame:
                            self._data_frame_values.append(flux_record.values)
                            pass

    def _prepare_data_frame(self):
        from ..extras import pd

        # We have to create temporary DataFrame because we want to preserve default column values
        _temp_df = pd.DataFrame(self._data_frame_values)
        self._data_frame_values = []

        # Custom DataFrame index
        if self._data_frame_index:
            self._data_frame = self._data_frame.set_index(self._data_frame_index)
            _temp_df = _temp_df.set_index(self._data_frame_index)

        # Append data
        df = pd.concat([self._data_frame.astype(_temp_df.dtypes), _temp_df])

        if self._use_extension_dtypes:
            return df.convert_dtypes()
        return df

    def parse_record(self, table_index, table, csv):
        """Parse one record."""
        record = FluxRecord(table_index)

        for fluxColumn in table.columns:
            column_name = fluxColumn.label
            str_val = csv[fluxColumn.index + 1]
            record.values[column_name] = self._to_value(str_val, fluxColumn)
            record.row.append(record.values[column_name])

        return record

    def _to_value(self, str_val, column):

        if str_val == '' or str_val is None:
            default_value = column.default_value
            if default_value == '' or default_value is None:
                if self._serialization_mode is FluxSerializationMode.dataFrame:
                    if self._use_extension_dtypes:
                        from ..extras import pd
                        return pd.NA
                    return None
                return None
            return self._to_value(default_value, column)

        if "string" == column.data_type:
            return str_val

        if "boolean" == column.data_type:
            return "true" == str_val

        if "unsignedLong" == column.data_type or "long" == column.data_type:
            return int(str_val)

        if "double" == column.data_type:
            return float(str_val)

        if "base64Binary" == column.data_type:
            return base64.b64decode(str_val)

        if "dateTime:RFC3339" == column.data_type or "dateTime:RFC3339Nano" == column.data_type:
            return get_date_helper().parse_date(str_val)

        if "duration" == column.data_type:
            # todo better type ?
            return int(str_val)

    @staticmethod
    def add_data_types(table, data_types):
        """Add data types to columns."""
        for index in range(1, len(data_types)):
            column_def = FluxColumn(index=index - 1, data_type=data_types[index])
            table.columns.append(column_def)

    @staticmethod
    def add_groups(table, csv):
        """Add group keys to columns."""
        i = 1
        for column in table.columns:
            column.group = csv[i] == "true"
            i += 1

    @staticmethod
    def add_default_empty_values(table, default_values):
        """Add default values to columns."""
        i = 1
        for column in table.columns:
            column.default_value = default_values[i]
            i += 1

    @staticmethod
    def add_column_names_and_tags(table, csv):
        """Add labels to columns."""
        if len(csv) != len(set(csv)):
            message = f"""The response contains columns with duplicated names: '{csv}'.

You should use the 'record.row' to access your data instead of 'record.values' dictionary.
"""
            warnings.warn(message, UserWarning)
            print(message)
        i = 1
        for column in table.columns:
            column.label = csv[i]
            i += 1

    def _insert_table(self, table, table_index):
        if self._serialization_mode is FluxSerializationMode.tables:
            self.tables.insert(table_index, table)

    def _is_profiler_record(self, flux_record: FluxRecord) -> bool:
        if not self._profilers:
            return False

        for profiler in self._profilers:
            if "_measurement" in flux_record.values and flux_record["_measurement"] == "profiler/" + profiler:
                return True

        return False

    def _is_profiler_table(self, table: FluxTable) -> bool:

        if not self._profilers:
            return False

        return any(filter(lambda column: (column.default_value == "_profiler" and column.label == "result"),
                          table.columns))

    def table_list(self) -> TableList:
        """Get the list of flux tables."""
        if not self._profilers:
            return self.tables
        else:
            return TableList(filter(lambda table: not self._is_profiler_table(table), self.tables))

    def _print_profiler_info(self, flux_record: FluxRecord):
        if flux_record.get_measurement().startswith("profiler/"):
            if self._profiler_callback:
                self._profiler_callback(flux_record)
            else:
                msg = "Profiler: " + flux_record.get_measurement()
                print("\n" + len(msg) * "=")
                print(msg)
                print(len(msg) * "=")
                for name in flux_record.values:
                    val = flux_record[name]
                    if isinstance(val, str) and len(val) > 50:
                        print(f"{name:<20}: \n\n{val}")
                    elif val is not None:
                        print(f"{name:<20}: {val:<20}")


class _StreamReaderToWithAsyncRead:
    def __init__(self, response):
        self.response = response
        self.decoder = codecs.getincrementaldecoder(_UTF_8_encoding)()

    async def read(self, size: int) -> str:
        raw_bytes = (await self.response.read(size))
        if not raw_bytes:
            return self.decoder.decode(b'', final=True)
        return self.decoder.decode(raw_bytes, final=False)
