import warnings
from itertools import chain, repeat
from copy import copy

from sqlalchemy import inspect, TypeDecorator
from sqlalchemy import Column
from sqlalchemy.dialects import postgresql as pg
from sqlalchemy.ext.associationproxy import AssociationProxy
from sqlalchemy.ext.hybrid import hybrid_property

from typing import Union, Set, Mapping, Iterable, Tuple, FrozenSet, List
from sqlalchemy.ext.declarative import DeclarativeMeta
from sqlalchemy.orm import ColumnProperty, RelationshipProperty
from sqlalchemy.orm.base import InspectionAttr
from sqlalchemy.orm.interfaces import MapperProperty
from sqlalchemy.orm.util import AliasedClass
from sqlalchemy.sql.elements import BinaryExpression
from sqlalchemy.sql.type_api import TypeEngine

from mongosql import SA_12, SA_13
try: from sqlalchemy.ext.associationproxy import ColumnAssociationProxyInstance  # SA 1.3.x
except ImportError: ColumnAssociationProxyInstance = None


class ModelPropertyBags:
    """ Model Property Bags is the class that lets you get information about the model's columns.

    This is the class that binds them all together: Columns, Relationships, PKs, etc.
    All the meta-information about a certain Model is stored here:

    - Columns
    - Relationships
    - Primary keys
    - Nullable columns
    - Properties and Hybrid Properties
    - Columns of related models
    - Writable properties

    Whenever it's too much to inspect several properties, use a `CombinedBag()` over them,
    which lets you get a column from a number of bags.
    """
    __bags_per_model_cache = {}

    @classmethod
    def for_model(cls, model: DeclarativeMeta) -> 'ModelPropertyBags':
        """ Get bags for a model.

        Please use this method over __init__(), because it initializes those bags only once
        """
        # The goal of this method is to only initialize a ModelPropertyBags only once per model.
        # Previously, we used to store them inside model attributes.

        try:
            # We want ever model class to have its own ModelPropertyBags,
            # and we want no one to inherit it.
            # We could use model.__dict__ for this, but classes in Python 3 use an immutable `mappingproxy` instead.
            # Thus, we have to keep our own cache of ModelPropertyBags.
            return cls.__bags_per_model_cache[model]
        except KeyError:
            cls.__bags_per_model_cache[model] = bags = cls(model)
            return bags

    @classmethod
    def for_alias(cls, aliased_model: AliasedClass) -> 'ModelPropertyBags':
        """ Get bags for an aliased class """
        model = inspect(aliased_model).class_
        return cls.for_model(model).aliased(aliased_model)

    @classmethod
    def for_model_or_alias(cls, target: Union[DeclarativeMeta, AliasedClass]) -> 'ModelPropertyBags':
        """ Get bags for a model, or aliased(model) """
        if inspect(target).is_aliased_class:
            return cls.for_alias(target)
        else:
            return cls.for_model(target)

    def __init__(self, model: DeclarativeMeta):
        """ Init bags

        :param model: Model
        :type model: sqlalchemy.ext.declarative.DeclarativeMeta
        """
        # We don't tolerate aliases here
        if inspect(model).is_aliased_class:
            raise TypeError('MongoPropertyBags does not tolerate aliased() models.'
                            'If you do really need to use one, do it this way: '
                            'ModelPropertyBags.for_alias(aliased_model)')

        # Get the inspector
        insp = inspect(model)

        # Initialize
        self.model = model
        self.model_name = model.__name__

        # Init bags: after every column type
        self.columns = self._init_columns(model, insp)
        self.properties = self._init_properties(model, insp)
        self.hybrid_properties = self._init_hybrid_properties(model, insp)
        self.association_proxies = self._init_association_proxies(model, insp)
        self.relations = self._init_relations(model, insp)
        self.related_columns = self._init_related_columns(model, insp)

        # Additional informational bags
        self.pk = self._init_primary_key(model, insp)
        self.nullable = self._init_nullable_columns(model, insp)

        # Writable entities
        self.writable_properties = self._init_writable_properties(model, insp)
        self.writable_hybrid_properties = self._init_writable_hybrid_properties(model, insp)

        self.writable = CombinedBag(
            # Everything that's writable in a model (excluding relations)
            col=self.columns,
            prop=self.writable_properties,
            hybrid=self.writable_hybrid_properties,
        )

    # region: Initialize bags

    # A bunch of initialization methods
    # This way, you can override the way a model is analyzed, and bags initialized

    def _init_columns(self, model, insp):
        """ Initialize: Column properties """
        return DotColumnsBag(_get_model_columns(model, insp))

    def _init_properties(self, model, insp):
        """ Initialize: Calculated properties: @property """
        return PropertiesBag(_get_model_properties(model, insp))

    def _init_hybrid_properties(self, model, insp):
        """ Initialize: Hybrid properties """
        return HybridPropertiesBag(_get_model_hybrid_properties(model, insp))

    def _init_association_proxies(self, model, insp):
        """ Initialize: association proxies """
        return AssociationProxiesBag(_get_model_association_proxies(model, insp))

    def _init_relations(self, model, insp):
        """ Initialize: Relationships and related columns """
        #: Relationship properties
        relationships_dict = _get_model_relationships(model, insp)
        return RelationshipsBag(relationships_dict)

    def _init_related_columns(self, model, insp):
        #: Related column properties
        relationships_dict = _get_model_relationships(model, insp)
        return DotRelatedColumnsBag(relationships_dict)

    def _init_primary_key(self, model, insp):
        """ Initialize: Primary key columns """
        #: Primary key columns
        return PrimaryKeyBag({c.name: self.columns[c.name]
                              for c in insp.primary_key})

    def _init_nullable_columns(self, model, insp):
        """ Initialize: Nullable columns """
        #: Nullable columns
        return ColumnsBag({name: c
                           for name, c in self.columns
                           if c.nullable})

    def _init_writable_properties(self, model, insp):
        """ Initialize: writable properties """
        return PropertiesBag({name: None
                              for name in self.properties.names
                              if _is_property_writable(getattr(model, name))})

    def _init_writable_hybrid_properties(self, model, insp):
        """ Initialize: writable Hybrid properties """
        return HybridPropertiesBag({name: prop
                                    for name, prop in self.hybrid_properties
                                    if _is_property_writable(prop)})

    # endregion

    def aliased(self, aliased_class: AliasedClass):
        # Return a wrapper that will lazily apply aliased() on every property when accessed
        # This makes sense because we don't know which of the bags are going to be actually used,
        # and aliased() has a bit of overhead: it involves copying the whole class.
        # Benchmarks have shown that it's about 3 times faster.
        return _MPB_LazyAliasedWrapper(self.__dict__, aliased_class)

    @property
    def all_names(self) -> Set[str]:
        """ Get the names of all properties defined for the model """
        return self.columns.names | \
               self.properties.names | \
               self.hybrid_properties.names | \
               self.association_proxies.names | \
               self.relations.names


class _PropertiesBagBase:
    """ Base class for Property bags:

    A container that keeps meta-information on SqlAlchemy stuff, like:
    - Columns
    - Primary keys
    - Relations
    - Related columns
    - Hybrid properties
    - Regular python properties

    There typically is a class that implements specific needs for every kind of property.

    Since there are so many different container types, there's one, CombinedBag(), that can
    handle them all, depending on the context.
    """

    def __init__(self) -> None:
        super().__init__()
        self._aliased_insp = None

    def __contains__(self, name: str) -> bool:
        """ Test if the property is in the bag
        :param name: Property name
        """
        raise NotImplementedError

    def __getitem__(self, name: str) -> MapperProperty:
        """ Get the property by name
        :param name: Property name
        """
        raise NotImplementedError

    def __copy__(self) -> '_PropertiesBagBase':
        """ Copy behavior is used to make an AliasedBag """
        cls = self.__class__
        result = cls.__new__(cls)
        result.__dict__.update(self.__dict__)
        return result

    def aliased(self, aliased_class) -> '_PropertiesBagBase':
        """ Get a version of this bag for using with an aliased class """
        new = copy(self)
        new._aliased_insp = inspect(aliased_class)
        return new

    @property
    def names(self) -> FrozenSet[str]:
        """ Get the set of names """
        raise NotImplementedError

    def __iter__(self) -> Mapping[str, MapperProperty]:
        """ Get all items """
        raise NotImplementedError

    def get_invalid_names(self, names: Iterable[str]) -> Set[str]:
        """ Get the names of invalid items

        Use this for validation.
        """
        return set(names) - self.names


class PropertiesBag(_PropertiesBagBase):
    """ Contains simple model properties (@property) """

    def __init__(self, properties: Mapping[str, None]):
        super(PropertiesBag, self).__init__()
        self._property_names = frozenset(properties.keys())

    @property
    def names(self) -> FrozenSet[str]:
        """ Get the set of property names """
        return self._property_names

    def __contains__(self, prop_name: str) -> bool:
        return prop_name in self._property_names

    def __getitem__(self, prop_name: str) -> None:
        if prop_name in self._property_names:
            return None
        raise KeyError(prop_name)

    def __iter__(self) -> Iterable[Tuple[str, None]]:
        return iter(zip(self._property_names, repeat(None)))


class _ColumnLikeAttrsBagBase(_PropertiesBagBase):
    """ Bag for column-like attributes (like association proxies) """

    def __init__(self, column_like_attrs: Mapping[str, InspectionAttr]):
        """ Init Association Proxies """
        super(_ColumnLikeAttrsBagBase, self).__init__()
        self._columns = column_like_attrs
        self._column_names = frozenset(self._columns.keys())

    @property
    def names(self) -> FrozenSet[str]:
        return self._column_names

    def __iter__(self) -> Iterable[Tuple[str, InspectionAttr]]:
        return iter(self._columns.items())

    def __contains__(self, name: str) -> bool:
        return name in self._column_names

    def __getitem__(self, column_name: str) -> InspectionAttr:
        return self._columns[column_name]

    def is_column_array(self, name: str) -> bool:
        """ Is the column an ARRAY column """
        raise NotImplementedError

    def is_column_json(self, name: str) -> bool:
        """ Is the column a JSON column """
        raise NotImplementedError


class ColumnsBag(_ColumnLikeAttrsBagBase):
    """ Columns bag

    Contains meta-information about columns:
    - which of them are ARRAY, or JSON types
    - list of their names
    - list of all columns
    - getting a column by name: bag[column_name]
    """

    def __init__(self, columns: Mapping[str, ColumnProperty]):
        """ Init columns

        :param columns: Model columns
        """
        super(ColumnsBag, self).__init__(columns)

        # More info about columns based on their type
        self._array_column_names = frozenset(name
                                             for name, col in self._columns.items()
                                             if _is_column_array(col))
        self._json_column_names =  frozenset(name
                                             for name, col in self._columns.items()
                                             if _is_column_json(col))

    def aliased(self, aliased_class: AliasedClass):
        return DictOfAliasedColumns.aliased_attrs(
            aliased_class,
            super(ColumnsBag, self).aliased(aliased_class),
            '_columns'
        )

    def is_column_array(self, name: str) -> bool:
        column_name = get_plain_column_name(name)
        return column_name in self._array_column_names

    def is_column_json(self, name: str) -> bool:
        column_name = get_plain_column_name(name)
        return column_name in self._json_column_names


class HybridPropertiesBag(ColumnsBag):
    """ Contains hybrid properties of a model """

    class _Hack_Lazy_Dict:
        """ A Lazy dict that only loads its keys upon request """
        __slots__ = ('_l', '_ks')

        def __init__(self, keys, lambda_value):
            self._ks = keys
            self._l = lambda_value

        def __getitem__(self, key):
            return self._l(key)

        def items(self):
            return ((k, self._l(k))
                    for k in self._ks)

    def aliased(self, aliased_class: AliasedClass) -> 'HybridPropertiesBag':
        new = super(HybridPropertiesBag, self).aliased(aliased_class)
        # For some reason, hybrid properties do not get a proper alias with adapt_to_entity()
        # We have to get them the usual way: from the entity
        # TODO: This method is a hack and is not supposed to be here at all. I've got to find out
        #   why hybrid methods got through this wrapper dictionary are not getting a proper alias!
        #   It seems that adapt_to_entity() is somehow insufficient. Perhaps, it is only manifest
        #   when an alias has an explicitly set name with aliased(name=...) ?
        #   When the bug is solved, this method should be removed completely.

        # new._columns = {col_name: getattr(aliased_class, col_name)
        #                 for col_name in self._column_names}
        # Don't use a real dict; use a lazy wrapper
        new._columns = self._Hack_Lazy_Dict(
            self._column_names,
            lambda col_name: getattr(aliased_class, col_name))

        return new


class PrimaryKeyBag(ColumnsBag):
    """ Primary Key Bag

    Like ColumnBag, but with a fancy name :)
    """


class DotColumnsBag(ColumnsBag):
    """ Columns bag with additional capabilities:

        - For JSON fields: field.prop.prop -- dot-notation access to sub-properties
    """

    def __contains__(self, name: str) -> bool:
        column_name, path = _dot_notation(name)
        return super(DotColumnsBag, self).__contains__(column_name)

    def __getitem__(self, name: str) -> Union[ColumnProperty, BinaryExpression]:
        column_name, path = _dot_notation(name)
        col = super(DotColumnsBag, self).__getitem__(column_name)
        # JSON path
        if path:
            if self.is_column_json(column_name):
                col = col[path].astext
            else:
                raise KeyError(name)
        return col

    def get_column_name(self, name: str) -> str:
        """ Get a column name, not a JSON path """
        return get_plain_column_name(name)

    def get_column(self, name: str) -> ColumnProperty:
        """ Get a column, not a JSON path """
        return self[get_plain_column_name(name)]

    def get_invalid_names(self, names: Iterable[str]) -> Set[str]:
        # First, validate easy names
        invalid = super(DotColumnsBag, self).get_invalid_names(names)  #type: set
        # Next, among those invalid ones, give those with dot-notation a second change: they
        # might be JSON columns' fields!
        invalid -= {name
                    for name in invalid
                    if self.is_column_json(name)
                    }
        return invalid


class AssociationProxiesBag(_ColumnLikeAttrsBagBase):
    """ Bag for Association Proxies """

    # Implement those two methods so that it looks like a column

    def is_column_array(self, name: str) -> bool:
        # Well, even though this column is clearly an array, it does not behave like one when thought of in terms of
        # Postgres operators, because the underlying comparison is done to a scalar column.
        # Example: AssociationProxy to User.name will use the `name` column for comparisons, which is scalar!
        return False

    def is_column_json(self, name: str) -> bool:
        return False

    def get_relationship(self, assoc_proxy: ColumnAssociationProxyInstance):
        """ Get the underlying relationship """
        # Get the relationship
        relationship = assoc_proxy.local_attr

        # When aliased, the relationship has to be adapted
        if self._aliased_insp:
            relationship = relationship.adapt_to_entity(self._aliased_insp)

        # Done
        return relationship


class RelationshipsBag(_PropertiesBagBase):
    """ Relationships bag

    Keeps track of relationships of a model.
    """

    def __init__(self, relationships: Mapping[str, RelationshipProperty]):
        """ Init relationships
        :param relationships: Model relationships
        """
        super(RelationshipsBag, self).__init__()
        self._relations = relationships
        self._rel_names = frozenset(self._relations.keys())
        self._array_rel_names = frozenset(name
                                          for name, rel in self._relations.items()
                                          if _is_relationship_array(rel))

    def aliased(self, aliased_class: AliasedClass) -> 'RelationshipsBag':
        return DictOfAliasedColumns.aliased_attrs(
            aliased_class,
            super(RelationshipsBag, self).aliased(aliased_class),
            '_relations'
        )

    def is_relationship_array(self, name: str) -> bool:
        """ Is the relationship an array relationship? """
        return name in self._array_rel_names

    @property
    def names(self) -> FrozenSet[str]:
        """ Get the set of relation names """
        return self._rel_names

    def __iter__(self) -> Iterable[Tuple[str, RelationshipProperty]]:
        """ Get relationships """
        return iter(self._relations.items())

    def __contains__(self, name: str) -> bool:
        return name in self._relations

    def __getitem__(self, name: str) -> RelationshipProperty:
        return self._relations[name]

    def get_target_model(self, name: str) -> DeclarativeMeta:
        """ Get target model of a relationship """
        return self[name].property.mapper.class_


class DotRelatedColumnsBag(ColumnsBag):
    """ Relationships bag that supports dot-notation for referencing columns of a related model """

    def __init__(self, relationships: Mapping[str, ColumnProperty]):
        self._rel_bag = RelationshipsBag(relationships)

        #: Dot-notation mapped to columns: 'rel.col' => Column
        related_columns = {}
        #: Dot-notation mapped to target models: 'rel.col' => Model, and 'rel' => Model
        rel_col_2_model = {}

        # Collect columns from every relation
        for rel_name, relation in self._rel_bag:
            # Get the model
            model = relation.property.mapper.class_
            rel_col_2_model[rel_name] = model

            # Get the columns
            ins = inspect(model)
            cols = _get_model_columns(model, ins)  # TODO: support more attr types? hybrid? association proxy?

            # Remember all of them, using dot-notation
            for col_name, col in cols.items():
                key = '{}.{}'.format(rel_name, col_name)
                related_columns[key] = col
                rel_col_2_model[key] = model

        # Now, when we have enough information, call super().__init__
        # It will initialize:
        # `._columns`,
        # `._column_names`,
        # `._array_column_names`,
        # `._json_column_names`
        # Keep in mind that all of them are RELATED COLUMNS
        super(DotRelatedColumnsBag, self).__init__(related_columns)

        #: A mapping of related column names to target models
        #self._column_name_to_related_model = rel_col_2_model  # unused

    def aliased(self, aliased_class: AliasedClass) -> 'DotRelatedColumnsBag':
        new = DictOfAliasedColumns.aliased_attrs(
            aliased_class,
            super(DotRelatedColumnsBag, self).aliased(aliased_class),
            '_columns', #'_column_name_to_related_model',
        )
        new._rel_bag = new._rel_bag.aliased(aliased_class)
        return new

    def is_column_array(self, name: str) -> bool:
        # not dot-notation filter like in the parent class: check as is!
        return name in self._array_column_names

    def is_column_json(self, name: str) -> bool:
        # not dot-notation filter like in the parent class: check as is!
        return name in self._json_column_names

    def get_relationship_name(self, col_name: str) -> str:
        return _dot_notation(col_name)[0]

    def get_related_column_name(self, col_name: str) -> str:
        return _dot_notation(col_name)[1]

    def get_relationship(self, col_name: str) -> RelationshipProperty:
        return self._rel_bag[self.get_relationship_name(col_name)]

    def is_relationship_array(self, col_name: str) -> bool:
        """ Is this relationship an array?

            This method accepts both relationship names and its column names.
            That is, both 'users' and 'users.id' will actually tell you about a relationship itself.
        """
        rel_name = get_plain_column_name(col_name)
        return self._rel_bag.is_relationship_array(rel_name)


class FakeBag(_PropertiesBagBase):
    """ A bag that supports dot-notation and contains fake column names that do not actually exist.

        This is used to support legacy columns. They are assumed to support dot-notation.
    """

    def __init__(self, fake_columns: Mapping[str, None]):
        super(FakeBag, self).__init__()
        self._fake_columns = fake_columns
        self._fake_column_names = frozenset(self._fake_columns.keys())

    def aliased(self, aliased_class: AliasedClass):
        return self  # same thing

    @property
    def names(self) -> FrozenSet[str]:
        return self._fake_column_names

    def __iter__(self) -> Iterable[Tuple[str, None]]:
        return iter(self._fake_columns.items())

    def __contains__(self, name: str) -> bool:
        return get_plain_column_name(name) in self._fake_columns

    def __getitem__(self, name: str) -> Union[ColumnProperty, BinaryExpression]:
        return self._fake_columns[get_plain_column_name(name)]

    def get_invalid_names(self, names: Iterable[str]) -> Set[str]:
        return {name
                for name in names
                if get_plain_column_name(name) not in self._fake_column_names
                }


class CombinedBag(_PropertiesBagBase):
    """ A bag that combines elements from multiple bags.

    This one is used when something can handle both columns and relationships, or properties and
    columns. Because this depends on what you're doing, this generalized implementation is used.

    In order to initialize it, you give them the bags you need as a dict:

        cbag = CombinedBag(
            col=bags.columns,
            rel=bags.related_columns,
        )

    Now, when you get an item, you get the aliased name that you have used:

        bag_name, bag, col = cbag['id']
        bag_name  #-> 'col'
        bag  #-> bags.columns
        col  #-> User.id

    This way, you can always tell which bag has the column come from, and handle it appropriately.
    """

    def __init__(self, **bags):
        super(CombinedBag, self).__init__()
        self._bags = bags

        # Combined names from all bags
        self._names = frozenset(chain(*(bag.names for bag in bags.values())))

        # Combined lookup by name from all bags
        self._bag_name_lookup_by_column_name = {
            column_name: bag_name
            for bag_name, bag  in self._bags.items()
            for column_name, column in bag
        }

        # List of JSON columns
        json_column_names = []
        for bag in self._bags.values():
            # We'll access a protected property, so got to make sure we've got the right class
            if isinstance(bag, ColumnsBag):
                # Get the list of JSON columns from a ColumnsBag
                json_column_names.extend(bag._json_column_names)
            elif isinstance(bag, FakeBag):
                # Get the list of fake columns from a Fake bag
                json_column_names.extend(bag.names)
        self._json_column_names = frozenset(json_column_names)

    def aliased(self, aliased_class: AliasedClass) -> 'CombinedBag':
        new = super(CombinedBag, self).aliased(aliased_class)
        # aliased() on every bag
        new._bags = {name: bag.aliased(aliased_class)
                     for name, bag in self._bags.items()}
        return new

    def bag(self, name) -> _PropertiesBagBase:
        """ Get a specific bag by name """
        return self._bags[name]

    def __contains__(self, name: str) -> bool:
        # Simple
        if name in self._names:
            return True
        # It might be a JSON column. Try it
        if get_plain_column_name(name) in self._json_column_names:
            return True
        # Nope. Nothing worked
        return False

    def __getitem__(self, name: str) -> Tuple[str, _PropertiesBagBase, MapperProperty]:
        # Get the column name: remove the '.'-notation only if the column is a json column
        plain_name = get_plain_column_name(name)
        plain_name = plain_name if plain_name in self._json_column_names else name
        # Locate the bag by quick lookup
        bag_name = self._bag_name_lookup_by_column_name[plain_name]
        # Get the column
        bag = self._bags[bag_name]
        # Done
        return (bag_name, bag, bag[name])

    def get_invalid_names(self, names: Iterable[str]) -> Set[str]:
        # This method is copy-paste from ColumnsBag
        # First, validate easy names
        invalid = super(CombinedBag, self).get_invalid_names(names)  # type: set
        # Next, among those invalid ones, give those with dot-notation a second change: they
        # might be JSON columns' fields!
        invalid -= {name
                    for name in invalid
                    if get_plain_column_name(name) in self._json_column_names
                    }
        return invalid

    def get(self, name: str) -> MapperProperty:
        """ Get a property """
        return self[name][2]

    @property
    def names(self) -> FrozenSet[str]:
        return self._names

    def __iter__(self) -> Iterable[Tuple[str, _PropertiesBagBase, str, MapperProperty]]:
        return (
            (bag_name, bag, column_name, column)
            for bag_name, bag in self._bags.items()
            for column_name, column in bag
        )


def _get_model_columns(model, ins):
    """ Get a dict of model columns """
    return {name: getattr(model, name)
            for name, c in ins.column_attrs.items()
            # ignore Labels and other stuff that .items() will always yield
            if isinstance(c.expression, Column)
            }


def _get_model_association_proxies(model, ins):
    """ Get a dict of model association_proxy attributes """
    # Ignore AssociationProxy attrs for SA 1.2.x
    if SA_12:
        warnings.warn('MongoSQL only supports AssociationProxy columns with SqlAlchemy 1.3.x')
        return {}

    return {name: getattr(model, name)
            for name, c in ins.all_orm_descriptors.items()
            if not name.startswith('_')
            and isinstance(c, AssociationProxy)}


def _get_model_hybrid_properties(model, ins):
    """ Get a dict of model hybrid properties """
    return {name: getattr(model, name)
            for name, c in ins.all_orm_descriptors.items()
            if not name.startswith('_')
            and isinstance(c, hybrid_property)}


def _get_model_properties(model, ins):
    """ Get a dict of model properties (calculated properties) """
    return {name: None  # we don't need the property itself
            for name in dir(model)
            if not name.startswith('_')
            and isinstance(getattr(model, name), property)}


def _get_model_relationships(model, ins):
    """ Get a dict of model relationships """
    return {name: getattr(model, name)
            for name, c in ins.relationships.items()}


def _get_column_type(col: MapperProperty) -> TypeEngine:
    """ Get column's SQL type """
    if isinstance(col.type, TypeDecorator):
        # Type decorators wrap other types, so we have to handle them carefully
        return col.type.impl
    else:
        return col.type


def _is_column_array(col: MapperProperty) -> bool:
    """ Is the column a PostgreSql ARRAY column? """
    return isinstance(_get_column_type(col), pg.ARRAY)


def _is_column_json(col: MapperProperty) -> bool:
    """ Is the column a PostgreSql JSON column? """
    return isinstance(_get_column_type(col), (pg.JSON, pg.JSONB))


def _is_relationship_array(rel: RelationshipProperty) -> bool:
    """ Is the relationship an array relationship? """
    return rel.property.uselist


def _is_property_writable(prop: property) -> bool:
    """ Check if a property is writable """
    return prop.fset is not None


def _dot_notation(name: str) -> Tuple[str, List[str]]:
    """ Split a property name that's using dot-notation.

    This is used to navigate the internals of JSON types:

        "json_column.property.property"
    """
    path = name.split('.')
    return path[0], path[1:]


def get_plain_column_name(name: str) -> str:
    """ Get a plain column name, dropping any dot-notation that may follow """
    return name.split('.')[0]


class DictOfAliasedColumns:
    """ A dict of columns that makes proper aliases upon access

        All our bags contain columns of a real model.
        However, in queries, we often need aliases, and need to get them transparently.

        To achieve that, we implement a dict that is capable of producing
        columns of an aliased model on demand.

        Upon access, adapt_to_entity() is called.
    """
    __slots__ = ('_d', '_a',)

    @classmethod
    def aliased_attrs(cls, aliased_class: AliasedClass, obj: object, *attr_names: str):
        """ Wrap a whole list of dictionaries into aliased wrappers """
        # Prepare AliasedInsp: this is what adapt_to_entity() wants
        aliased_inspector = inspect(aliased_class)
        assert aliased_inspector.is_aliased_class, '`aliased_class` must be an alias!'

        # Convert every attribute
        for attr_name in attr_names:
            setattr(obj, attr_name,
                    # Wrap it with self
                    cls(getattr(obj, attr_name),
                        aliased_inspector)
                    )

        # Done
        return obj

    def __init__(self, columns_dict, aliased_insp):
        """ Make a dict of columns, ready to alias them as needed """
        self._d = columns_dict
        self._a = aliased_insp

    def _adapt_to_entity(self, attr):
        """ Helper to adapt properties to aliases """
        return attr.adapt_to_entity(self._a)

    # adapters

    def __contains__(self, key):
        return key in self._d

    def __getitem__(self, key):
        return self._adapt_to_entity(self._d[key])

    def values(self):
        return (self._adapt_to_entity(c)
                for c in self._d.values())

    def items(self):
        return ((k, self._adapt_to_entity(c))
                for k, c in self._d.items())


class _MPB_LazyAliasedWrapper:
    """ A ModelPropertyBags wrapper that will lazily apply aliased() on every attribute upon access """
    def __init__(self, mpb_dict: dict, aliased_class: AliasedClass):
        self.__aliased_class = aliased_class

        # Remember those attributes that were not aliased() yet
        self.__unaliased = {}

        # Tell attributes apart:
        # set the bags aside for later aliased()ing,
        # but put all other attributes onto ourselves
        for k, v in mpb_dict.items():
            if isinstance(v, _PropertiesBagBase):
                self.__unaliased[k] = mpb_dict[k]
            else:
                setattr(self, k, v)  # onto ourselves

    def __getattr__(self, attr: str):
        # Initialize a new attribute that's aliased()
        setattr(self,
                attr,
                self.__unaliased.pop(attr).aliased(self.__aliased_class)
                )

        # return it
        return getattr(self, attr)