from typing import ClassVar, Iterator, Optional

from frozendict import frozendict
import pandas as pd

from ..descriptors.datatypes import nxdata
from ..descriptors.dataspec import DataSpec
from ..descriptors.schema import IndexSchema
from ..descriptors.scope import DataScope
from ..descriptors import metadata, metamethod, metaproperty

from ..meta import archetype

from .base import DataObject
from .data import Data


# For now this archetype serves as a general-purpose series representation
# but with no underlying schema it doesn't specify the indexing scheme at
# the type level. Instead (and pending) it exposes an index as metadata at
# the instance level. The primary use case is to represent columns extracted
# from a Table instance. In that case, the resulting data object inherits the
# index of the Table instance. The gap this leaves is for first-class data Series
# that may be persisted as such on disk, and hence would require an index specification.
# This can initially be covered by a single-payload column Table -which would be
# generated by specifying a Record class whose schema has a single non-indexing
# field. The drawback of that approach is that a single-column dataframe is not
# quite the same interface as a series. Hence a more complete treatment would
# involve renaming the present archetype to Column, and offering a separate
# Series archetype that takes a Record type as its data specification, with the
# obvious constraint that the Record's schema features a single payload column.
# Addendum 2-18-22: Another distinction that needs to be made is between a
# data structure with an arbitrary index and one in which the index is obtained
# through a range query -that may be the better way to think about Column vs
# Series.
@archetype
class Column(DataObject):
    datatype: type[Data] = metadata(typespec=True)
    index_schema: Optional[IndexSchema] = metadata(objectspec=True)
    datascope: Optional[DataScope] = metadata(objectspec=True)

    @datatype.parser
    def _convert_datatype(value):
        if isinstance(value, DataSpec):
            return Data[value]
        elif isinstance(value, type):
            if issubclass(value, Data):
                return value
            elif issubclass(value, nxdata):
                return Data[DataSpec(value)]

    @metaproperty
    def dataspec(datatype) -> DataSpec:
        return getattr(datatype, "dataspec", DataSpec(datatype))

    @metamethod
    def preprocessor(cls, datatype: type[nxdata], **metadata):
        """Method invoked by __init__ to resolve data inputs."""
        name = getattr(getattr(datatype, "dataspec", None), "name", None)

        @classmethod
        def preprocess(cls_, data, **kwargs):
            if isinstance(data, pd.Series):
                return data
            try:
                series = pd.Series(data)
                series.name = name
                return series
            except (ValueError, TypeError):
                msg = f"{cls_} could not interpret {data}"
                raise TypeError(msg)

        return preprocess

    # The following methods are obviously very suboptimal. They will be
    # replaced by an implementation based on Pandera.
    @metamethod
    def parser(cls, datatype: type[nxdata], **metadata):
        dataspec = getattr(datatype, "dataspec", DataSpec(datatype))
        model_cls = dataspec.model_class(namespace=datatype, parse=True, validate=False)

        @classmethod
        def parse(cls_, data: pd.Series):
            return data.apply(lambda v: getattr(model_cls(data=v), "data"))

        return parse

    @metamethod
    def conformer(
        cls,
        datatype: type[nxdata],
        **metadata,
    ):
        dataspec: Optional[DataSpec] = getattr(datatype, "dataspec", None)
        name = getattr(dataspec, "name", None)
        categorical = getattr(dataspec, "extensions", {}).get("nx_key", False)

        @classmethod
        def conform(
            cls_, data: pd.Series, datascope: Optional[DataScope] = None, **metadata
        ):
            data.name = name
            if categorical:
                if datascope is None:
                    dtype = pd.CategoricalDtype()
                else:
                    key_categories = datascope.get("keys", None)
                    if isinstance(key_categories, frozendict):
                        categories = key_categories.get(name, None)
                    else:
                        categories = key_categories
                    if isinstance(categories, tuple):
                        dtype = pd.CategoricalDtype(categories=categories, ordered=True)
                    else:
                        dtype = pd.CategoricalDtype(categories=categories)
                return data.astype(dtype)
            return data

        return conform

    @metamethod
    def validator(cls, datatype: type[nxdata], **metadata):
        dataspec = getattr(datatype, "dataspec", DataSpec(datatype))
        model_cls = dataspec.model_class(namespace=datatype, parse=False, validate=True)

        def validate(self):
            data: pd.Series = self._data
            data.apply(lambda v: getattr(model_cls(data=v), "data"))
            return True

        return validate

    def __eq__(self, other: "Column"):
        try:
            data_equality = self.data.equals(other.data)
            type_compatibility = (self.dataspec >= other.dataspec) or (
                self.dataspec <= other.dataspec
            )
        except AttributeError:
            return False
        return data_equality and type_compatibility

    def __getattr__(self, name):
        if name in self._data.index.names:
            return self._data.index.get_level_values(name)
        raise AttributeError(f"{type(self)} object has no attribute '{name}'")

    def _row_from_iloc(self, position: int) -> Data:
        value = self._data.iloc[position]
        index_names = self._data.index.names
        if index_names[0] is not None:
            dataindex = frozendict(zip(index_names, self._data.index[position]))
        else:
            dataindex = None
        return self.datatype(
            value,
            dataindex=dataindex,
            parse=False,
            conform=False,
            validate=False,
            integrate=False,
        )

    def _slice_from_iloc(self, *slice_args) -> "Column":
        slice_ = slice(*slice_args)
        data = self._data.iloc[slice_]
        return type(self)(
            data, parse=False, conform=False, validate=False, integrate=False
        )

    def values(self) -> Iterator[Data]:
        """Returns an iterator of data values making up self."""
        return (self._row_from_iloc(i) for i in range(self._data.size))
