# copyright 2003-2015 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact https://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb.  If not, see <https://www.gnu.org/licenses/>.
"""some hooks to handle notification on entity's changes"""


from logilab.common.textutils import normalize_text
from logilab.common.registry import RegistryNotFound

from cubicweb.predicates import is_instance
from cubicweb.server import hook
from cubicweb.sobjects.supervising import SupervisionMailOp


def notify_on_commit(cnx, view, viewargs=None):
    """register a notification view (see
    :class:`~cubicweb.sobjects.notification.NotificationView`) to be sent at
    post-commit time, ie only if the transaction has succeeded.

    `viewargs` is an optional dictionary containing extra argument to be given
    to :meth:`~cubicweb.sobjects.notification.NotificationView.render_and_send`
    """
    if viewargs is None:
        viewargs = {}
    notif_op = _RenderAndSendNotificationOp.get_instance(cnx)
    notif_op.add_data((view, viewargs))


class _RenderAndSendNotificationOp(hook.DataOperationMixIn, hook.Operation):
    """End of the notification chain. Do render and send views after commit

    All others Operations end up adding data to this Operation.
    The notification are done on ``postcommit_event`` to make sure to prevent
    sending notification about rolled back data.
    """

    containercls = list

    def postcommit_event(self):
        deleted = self.cnx.deleted_in_transaction
        views_to_render = self.get_data()
        for view, viewargs in views_to_render:
            if view.cw_rset is not None:
                if not view.cw_rset:
                    # entity added and deleted in the same transaction
                    # (cache effect)
                    continue
                elif deleted(view.cw_rset[view.cw_row or 0][view.cw_col or 0]):
                    # entity added and deleted in the same transaction
                    continue
            try:
                view.render_and_send(**viewargs)
            except Exception:
                # error in post commit are not propagated
                # We keep this logic here to prevent a small notification error
                # to prevent them all.
                self.exception("Notification failed")

                if self.cnx.vreg.config.mode == "test":
                    # reraise in testing context because we actually want to
                    # have those exceptions here and that self.exception is
                    # filtered in test context
                    raise


class NotificationHook(hook.Hook):
    __abstract__ = True
    category = "notification"

    def select_view(self, vid, rset, row=0, col=0):
        try:
            return self._cw.vreg["notification-views"].select_or_none(
                vid, self._cw, rset=rset, row=row, col=col
            )
        except RegistryNotFound:  # can happen in some config
            # (e.g. repo only config with no
            # notification views registered by
            # the instance's cubes)
            return None


class StatusChangeHook(NotificationHook):
    """notify when a workflowable entity has its state modified"""

    __regid__ = "notifystatuschange"
    __select__ = NotificationHook.__select__ & is_instance("TrInfo")
    events = ("after_add_entity",)

    def __call__(self):
        entity = self.entity
        if not entity.from_state:  # not a transition
            return
        rset = entity.related("wf_info_for")
        view = self.select_view("notif_status_change", rset=rset, row=0)
        if view is None:
            return
        comment = entity.printable_value("comment", format="text/plain")
        # XXX don't try to wrap rest until we've a proper transformation (see
        # #103822)
        if comment and entity.comment_format != "text/rest":
            comment = normalize_text(comment, 80)
        viewargs = {
            "comment": comment,
            "previous_state": entity.previous_state.name,
            "current_state": entity.new_state.name,
        }
        notify_on_commit(self._cw, view, viewargs=viewargs)


class RelationChangeHook(NotificationHook):
    __regid__ = "notifyrelationchange"
    events = (
        "before_add_relation",
        "after_add_relation",
        "before_delete_relation",
        "after_delete_relation",
    )

    def __call__(self):
        """if a notification view is defined for the event, send notification
        email defined by the view
        """
        rset = self._cw.eid_rset(self.eidfrom)
        view = self.select_view(f"notif_{self.event}_{self.rtype}", rset=rset, row=0)
        if view is None:
            return
        notify_on_commit(self._cw, view)


class EntityChangeHook(NotificationHook):
    """if a notification view is defined for the event, send notification
    email defined by the view
    """

    __regid__ = "notifyentitychange"
    events = ("after_add_entity", "after_update_entity")

    def __call__(self):
        rset = self.entity.as_rset()
        view = self.select_view(f"notif_{self.event}", rset=rset, row=0)
        if view is None:
            return
        notify_on_commit(self._cw, view)


class EntityUpdatedNotificationOp(hook.SingleLastOperation):
    """scrap all changed entity to prepare a Notification Operation for them"""

    def precommit_event(self):
        # precommit event that creates postcommit operation
        cnx = self.cnx
        for eid in cnx.transaction_data["changes"]:
            view = cnx.vreg["notification-views"].select(
                "notif_entity_updated", cnx, rset=cnx.eid_rset(eid), row=0
            )
            notify_on_commit(
                self.cnx,
                view,
                viewargs={"changes": cnx.transaction_data["changes"][eid]},
            )


class EntityUpdateHook(NotificationHook):
    __regid__ = "notifentityupdated"
    __abstract__ = True  # do not register by default
    __select__ = NotificationHook.__select__ & hook.issued_from_user_query()
    events = ("before_update_entity",)
    skip_attrs = {"modification_date"}

    def __call__(self):
        cnx = self._cw
        if cnx.added_in_transaction(self.entity.eid):
            return  # entity is being created
        # then compute changes
        attrs = [k for k in self.entity.cw_edited if k not in self.skip_attrs]
        if not attrs:
            return
        changes = cnx.transaction_data.setdefault("changes", {})
        thisentitychanges = changes.setdefault(self.entity.eid, set())
        rqlsel, rqlrestr = [], ["X eid %(x)s"]
        for i, attr in enumerate(attrs):
            var = chr(65 + i)
            rqlsel.append(var)
            rqlrestr.append(f"X {attr} {var}")
        rql = f"Any {','.join(rqlsel)} WHERE {','.join(rqlrestr)}"
        rset = cnx.execute(rql, {"x": self.entity.eid})
        for i, attr in enumerate(attrs):
            oldvalue = rset[0][i]
            newvalue = self.entity.cw_edited[attr]
            if oldvalue != newvalue:
                thisentitychanges.add((attr, oldvalue, newvalue))
        if thisentitychanges:
            EntityUpdatedNotificationOp(cnx)


# supervising ##################################################################


class SomethingChangedHook(NotificationHook):
    __regid__ = "supervising"
    __select__ = NotificationHook.__select__ & hook.issued_from_user_query()
    events = (
        "before_add_relation",
        "before_delete_relation",
        "after_add_entity",
        "before_update_entity",
    )

    def __call__(self):
        dest = self._cw.vreg.config["supervising-addrs"]
        if not dest:  # no supervisors, don't do this for nothing...
            return
        if self._call():
            SupervisionMailOp(self._cw)

    def _call(self):
        event = self.event.split("_", 1)[1]
        if event == "update_entity":
            if self._cw.added_in_transaction(self.entity.eid):
                return False
            if self.entity.e_schema == "CWUser":
                if not (
                    frozenset(self.entity.cw_edited)
                    - frozenset(("eid", "modification_date", "last_login_time"))
                ):
                    # don't record last_login_time update which are done
                    # automatically at login time
                    return False
        self._cw.transaction_data.setdefault("pendingchanges", []).append((event, self))
        return True


class EntityDeleteHook(SomethingChangedHook):
    __regid__ = "supervisingentitydel"
    events = ("before_delete_entity",)

    def _call(self):
        try:
            title = self.entity.dc_title()
        except Exception:
            # may raise an error during deletion process, for instance due to
            # missing required relation
            title = f"#{self.entity.eid}"
        self._cw.transaction_data.setdefault("pendingchanges", []).append(
            ("delete_entity", (self.entity.eid, self.entity.cw_etype, title))
        )
        return True
