#!/usr/bin/env python3

# MIT License
#
# Copyright (c) 2017 Benedikt Schmitt <benedikt@benediktschmitt.de>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

# third party
import sqlalchemy
from sqlalchemy import Column, String
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method


__all__ = [
    "MaterializedPathMixin",
    "register_events",
    "unregister_events"
]


#: The current version of this library.
VERSION = "0.0.0"


class MaterializedPathMixin(object):
    """
    A mixin for the materialized path pattern which allows to store hierarchical
    data in an SQL database.

    The model used with the mixin must have an integer valued primary key column named *id*.

    Examples for materialized paths:

        *   ".1"    Root as parent
        *   ".1.2"  Root -> 1 -> self

    .. hint::

        Don't forget to register the events for your model with :func:`register_events`.
    """

    _mat_path = Column("materialized_path", String(), nullable=False, index=True)

    def __init__(self, parent=None):
        self.move(parent)
        return None

    @hybrid_property
    def path(self):
        """The materialized path to the node. The path can only be changed
        using the :meth:`move` method.
        """
        return self._mat_path

    @hybrid_property
    def __id(self):
        """A reference to the primary key defined in the actual model."""
        return self.id

    def move(self, parent):
        """Changes the parent of this node to *parent*."""
        parent_path = "" if parent is None else parent.path
        assert not "INIT:" in parent_path

        # Has the path been initialised yet?
        init = self._mat_path is None or self._mat_path.startswith("INIT:")

        # initialisation without id
        if init and self.__id is None:
            self._mat_path = "INIT:" + parent_path

        # initialisation with id
        elif init and self.__id is not None:
            self._mat_path = parent_path + "." + str(self.__id)

        # no initialisation
        else:
            if parent_path.startswith(self._mat_path):
                raise ValueError("Can not move to a descendant.")

            old_path = self._mat_path
            new_path = parent_path + "." + str(self.__id)
            for node in self.descendants:
                node._mat_path = new_path + node._mat_path[len(old_path):]
            self._mat_path = new_path
        return None


    @declared_attr
    def descendants(cls):
        """All descendants (not inclusive)."""
        from sqlalchemy.orm import remote, foreign, relationship

        primaryjoin = remote(foreign(cls._mat_path)).like(cls._mat_path.concat(".%"))
        descendants = relationship(
            cls.__name__, viewonly=True, order_by=cls._mat_path,
            primaryjoin=primaryjoin, lazy="dynamic", cascade="all,delete-orphan"
        )
        return descendants

    @hybrid_method
    def is_descendant_of(self, other):
        """Returns true if *other* is a real descendant of this node (not
        inclusive).
        """
        if other is None:
            return True
        return other.is_ancestor_of(self)


    @declared_attr
    def ancestors(cls):
        """All ancestors (not inclusive) in ascending tree order."""
        from sqlalchemy.orm import remote, foreign, relationship

        primaryjoin = cls._mat_path.like(remote(foreign(cls._mat_path)).concat(".%"))
        ancestors = relationship(
            cls.__name__, viewonly=True, order_by=cls._mat_path,
            primaryjoin=primaryjoin, lazy="dynamic"
        )
        return ancestors

    @hybrid_method
    def is_ancestor_of(self, other):
        """Returns true if *other* is a real ancestors of this node (not
        inclusive).
        """
        if other is None:
            return False
        return other._mat_path.startswith(self._mat_path + ".")

    @is_ancestor_of.expression
    def is_ancestor_of(cls, other):
        from sqlalchemy.sql import func, type_coerce

        if other is None:
            return False
        return type_coerce(other._mat_path, String).like(cls._mat_path.concat(".%"))


    @declared_attr
    def children(cls):
        """All children (direct ancestors) of this node."""
        from sqlalchemy.orm import remote, foreign, relationship

        child_path1 = remote(foreign(cls._mat_path))
        child_path2 = cls._mat_path.concat(".").concat(remote(foreign(cls.__id)))
        primaryjoin = (child_path1 == child_path2)

        children = relationship(
            cls.__name__, viewonly=True, primaryjoin=primaryjoin, lazy="dynamic"
        )
        return children

    @hybrid_method
    def is_child_of(self, other):
        """Returns true if *other* is a real child of this node. (not
        inclusive).
        """
        other_path = "" if other is None else other._mat_path
        return other_path + "." + str(self.__id) == self._mat_path

    @is_child_of.expression
    def is_child_of(cls, other):
        from sqlalchemy.sql import type_coerce

        other_path = "" if other is None else other._mat_path
        return other_path + "." + type_coerce(cls.__id, String) == cls._mat_path

    @hybrid_property
    def has_children(self):
        """True if this node has any children."""
        from sqlalchemy.orm import object_session

        session = object_session(self)
        return session.query(self.children.exists()).scalar()

    @has_children.expression
    def has_children(cls):
        from sqlalchemy.orm import aliased
        from sqlalchemy.sql import exists

        alias = aliased(cls)
        sql = exists().where(alias._mat_path.like(cls._mat_path.concat(".%")))
        return sql


    @property
    def parent_id(self):
        """The id of the parent node."""
        path = self._mat_path.split(".")
        parent_id = int(path[-2]) if len(path) > 2 else None
        return parent_id

    @declared_attr
    def parent(cls):
        """The parent of this node."""
        from sqlalchemy.orm import remote, foreign, relationship

        primaryjoin = remote(foreign(cls._mat_path)).concat(".").concat(cls.__id) == cls._mat_path
        parent = relationship(
            cls.__name__, viewonly=True, primaryjoin=primaryjoin, uselist=False, lazy="select"
        )
        return parent

    @hybrid_method
    def is_parent_of(self, other):
        """Returns true if this node is the parent of *other*."""
        if other is None:
            return False
        return self._mat_path + "." + str(other.__id) == other._mat_path

    @is_parent_of.expression
    def is_parent_of(cls, other):
        if other is None:
            return False
        return cls._mat_path.concat(".").concat(other.__id) == other._mat_path


    @hybrid_property
    def level(self):
        """Returns the depth of the node in the tree (number of parents). Nodes
        with no parent have level 1.
        """
        return self._mat_path.count(".")

    @level.expression
    def level(cls):
        from sqlalchemy.sql import func

        count = lambda col, seq: func.length(col) - func.length(func.replace(col, seq, ""))
        return count(cls._mat_path, ".")


def after_insert(mapper, connection, path):
    """Complete the materialized path after insertion."""
    from sqlalchemy.orm.attributes import set_committed_value
    from sqlalchemy.sql import update

    # Get the mapped table.
    assert len(mapper.tables) == 1
    table = mapper.tables[0]

    # Nothing to do.
    if not path.path.startswith("INIT:"):
        return None

    # Replace the temporary path with the real, complete path.
    parent_path = path.path[len("INIT:"):]
    real_path = parent_path + "." + str(path.id)

    stmt = table.update().where(table.c.id == path.id).values(materialized_path=real_path)
    connection.execute(stmt)

    # Update the object without changing the history.
    set_committed_value(path, "_mat_path", real_path)
    assert path.path == real_path
    return None


def register_events(table):
    """Registers SQLAlchemy events for :class:`MaterializedPathMixin` subclass *table*."""
    assert issubclass(table, MaterializedPathMixin)
    sqlalchemy.event.listen(table, "after_insert", after_insert)
    return None


def unregister_events(table):
    """Unregisters the events which have previously been registered with
    :func:`register_events`.
    """
    assert issubclass(table, MaterializedPathMixin)
    sqlalchemy.event.remove(table, "after_insert", after_insert)
    return None
