from Acquisition import aq_base
from datetime import datetime
from plone import api
from plone.memoize.view import memoize
from plone.memoize.view import memoize_contextless
from plone.protect.interfaces import IDisableCSRFProtection
from Products.Five import BrowserView
from transaction import commit
from unittest import mock
from zope.event import notify
from zope.interface import alsoProvides
from zope.lifecycleevent import ObjectModifiedEvent

import logging
import time

try:
    import pas.plugins.ldap  # noqa: F401

    HAS_PASLDAP = True
except ImportError:
    HAS_PASLDAP = False

NO_VALUE = object()


logger = logging.getLogger(__name__)


class BaseSyncView(BrowserView):
    """
    This is the base class that is used to sync principal data from an external source
    """

    def _disable_csrf_protection(self):
        """Those view are usually called by cron"""
        alsoProvides(self.request, IDisableCSRFProtection)

    def _notify_modified(self, obj=None):
        if obj is None:
            obj = self.context
        notify(ObjectModifiedEvent(obj))

    @property
    def external_source_id(self):
        return api.portal.get_registry_record(
            "ploneintranet.userprofile.primary_external_user_source"
        )

    @property
    @memoize_contextless
    def external_source(self):
        return api.portal.get_tool("acl_users").get(self.external_source_id)

    def _purge_all_caches(self):
        """Purge everything, we want fresh data"""
        acl_users = api.portal.get_tool("acl_users")
        if acl_users.ZCacheable_enabled():
            acl_users.ZCacheable_invalidate()

    def record_last_sync(self, obj=None):
        """Record when obj was synced the last time
        If no obj is passed the context will be used
        """
        if obj is None:
            obj = self.context
        obj.last_sync = datetime.utcnow()

    def get_last_sync(self, obj=None):
        """Check when obj was synced the last time
        If no obj is passed the context will be used
        """
        if obj is None:
            obj = self.context
        return getattr(aq_base(obj), "last_sync", None)

    def _sync(self):
        """Override this to actually do something useful"""

    def __call__(self):
        self._purge_all_caches()
        self._disable_csrf_protection()
        self.request.environ["disable.auditlog"] = True

        if HAS_PASLDAP:
            # There is an is_plugin_active method in pas.plugins.ldap
            # that checks if the plugin is activated before running some code
            # We fake it is active so that we can use it as an external source
            # even if it is not active
            # TODO: since Python 3.7 there is a null context manager.
            # It can be used to make the code more slick
            with mock.patch(
                "pas.plugins.ldap.plugin.LDAPPlugin.is_plugin_active",
                side_effect=lambda *args: True,
            ):
                self._sync()
        else:
            self._sync()
        return "OK"


class BaseSyncManyView(BaseSyncView):
    """This is the base class that is used to sync a group of principals
    from an external source
    """

    _action_view = "sync"
    external_principal_ids = set()
    local_principal_ids = set()

    @property
    @memoize
    def principals(self):
        """Override this to specify a subset of principals to be updated."""
        return [
            principal
            for principal in self.context.objectValues()
            if principal.source == self.external_source_id
        ]

    def _get_principal_by_id(self, principal_id):
        """Override this method to return the principal you need"""

    def _disable_principal_by_id(self, principal_id):
        """This disables one principal"""

    def _disable_exceeding_principals(self):
        """This will disable the principals
        that are in Plone but not on the external source
        """
        exceeding_principal_ids = self.local_principal_ids - self.external_principal_ids
        principals = (
            self._get_principal_by_id(principal_id)
            for principal_id in exceeding_principal_ids
        )
        principals_to_disable = (
            principal
            for principal in principals
            if (
                principal.source == self.external_source_id
                and api.content.get_state(principal) != "disabled"
            )
        )
        for principal in principals_to_disable:
            try:
                api.content.transition(obj=principal, to_state="disabled")
            except Exception:
                logger.error("Cannot disable %r", principal)
                return
            logger.info("Principal disabled: %r", principal)

    def _enable_restored_principals(self):
        """This will enable the principals
        that are deactivated in Plone but active (again) on the external source
        """
        intersecting_principal_ids = self.local_principal_ids.intersection(
            self.external_principal_ids
        )
        principals = (
            self._get_principal_by_id(principal_id)
            for principal_id in intersecting_principal_ids
        )
        principals_to_enable = (
            principal
            for principal in principals
            if (
                principal.source == self.external_source_id
                and api.content.get_state(principal) == "disabled"
            )
        )
        for principal in principals_to_enable:
            try:
                api.content.transition(obj=principal, to_state="enabled")
            except Exception:
                logger.error("Cannot enable %r", principal)
                return
            logger.info("Principal re-enabled: %r", principal)

    def _create_principal_by_id(self, principal_id):
        """
        Override this method to do something useful to create the principal you need
        """

    @memoize_contextless
    def _create_missing_principals(self):
        """This will create the principals
        that are in the external source but not in Plone
        """
        missing_principal_ids = self.external_principal_ids - self.local_principal_ids
        counter = 0
        objs = []
        for principal_id in missing_principal_ids:
            try:
                obj = self._create_principal_by_id(principal_id)
            except Exception:
                logger.exception("Error creating principal: %r", principal_id)
                continue
            logger.info("Created principal: %r", obj)
            objs.append(obj)
            counter += 1
            if counter % 100 == 0:
                logger.info("Created %s profiles. Committing.", counter)
                commit()
        return objs

    def _sync_one(self, principal):
        """Code that we need to run when syncing a principal"""
        sync_view = api.content.get_view(self._action_view, principal, self.request)
        sync_view._sync()

    def _sync_many(self):
        """Invoke the _sync method of the _action_view for the users we need to sync"""
        principals = self.principals
        if not principals:
            logger.info("Nothing to sync")
            return

        start = time.time()
        count = 0
        skipped = 0
        for count, principal in enumerate(principals, start=1):
            if api.content.get_state(principal) == "disabled":
                skipped += 1
                continue
            try:
                self._sync_one(principal)
            except api.exc.InvalidParameterError:
                skipped += 1
                continue

            if count and not count % 100:
                logger.info("Synced %s principals. Committing.", count)
                commit()

        self.record_last_sync()
        duration = time.time() - start
        logger.info(
            "Updated {} (skipped {}) in {:0.2f} seconds".format(
                count, skipped, duration
            )
        )

    def _sync(self):
        """Sync properties from PAS into the membrane object for
        all users across the application.
        """
        self._disable_exceeding_principals()
        self._create_missing_principals()
        if api.portal.get_registry_record(
            "ploneintranet.userprofile.reactivate_on_sync", default=False
        ):
            self._enable_restored_principals()
        self._sync_many()
