"""Tests for synchronisation of user profiles with external sources.

Re-uses the ZODBMutablePropertyProvider as a example foreign property
provider.

Re-uses the 'source_users' plugin in order to simulate a primary
external user source.

"""

from datetime import datetime
from plone import api
from ploneintranet import api as pi_api
from ploneintranet.testing import temporary_registry_record
from ploneintranet.userprofile.browser.sync.base import NO_VALUE
from ploneintranet.userprofile.browser.sync.userprofile import SyncProperties
from ploneintranet.userprofile.tests.base import BaseTestCase
from Products.PlonePAS.plugins.property import ZODBMutablePropertyProvider
from unittest import mock

TESTING_PLUGIN_ID = "mock_ldap"
TEST_USER_PREFIX = "sync-user"


class MockPASPlugin(ZODBMutablePropertyProvider):
    """This is a test pas plugin that fakes a remote ldap connection

    It is based on ZODBMutablePropertyProvider to use the _storage BTree
    to provide info about the user

    The keys of the _storage will be used to return the list of the managed users
    """

    _groups = {}

    def enumerateUsers(self):
        """Return the list of users"""
        return [{"id": key} for key in self._storage]

    def getGroupMembers(self, group_id):
        return []

    def enumerateGroups(self, id=None, exact_match=False):
        """Mock the enumerate groups"""
        keys = list(self._groups)
        if id is not None:
            if exact_match:
                keys = [key for key in keys if key == id]
            else:
                keys = [key for key in keys if id in key]

        return [{"id": key} for key in keys]


def install_mock_pas_plugin():
    pp = MockPASPlugin(
        TESTING_PLUGIN_ID, "Mock LDAP", schema=(("email", "string", NO_VALUE),)
    )
    pp.meta_type = "Mock External PAS Users"
    acl_users = api.portal.get_tool("acl_users")
    acl_users[TESTING_PLUGIN_ID] = pp
    return acl_users[TESTING_PLUGIN_ID]


class SyncBaseTestCase(BaseTestCase):
    def setUp(self):
        super().setUp()
        self.external_source = install_mock_pas_plugin()

    def _get_member_ids(self):
        user_ids = {user.getId() for user in api.user.get_users()}
        return {user_id for user_id in user_ids if user_id.startswith(TEST_USER_PREFIX)}

    def _create_userprofile(self, username, properties):
        user = pi_api.userprofile.create(
            username=username,
            email=f"{username}@example.com",
            properties=properties,
            approve=True,
        )
        return user

    def _check_last_sync_set(self, context):
        self.assertIsInstance(context.last_sync, datetime)

    def _set_foreign_property(self, userid, key, value):
        self.external_source._storage[userid] = {key: value}


class TestProfileSyncCreation(SyncBaseTestCase):
    def test_sync_with_empty_plugin_raises(self):
        view = api.content.get_view(
            "sync-users", self.portal.profiles, self.request.clone()
        )
        with self.assertRaises(RuntimeError):
            view._sync()

    def test_sync_creates_missing_users_and_disables_exceeding_users_and_reactivates(
        self,
    ):
        # We some users to the external source
        self._set_foreign_property("janedoe", "email", "janedoe@example.com")
        self._set_foreign_property("johndoe", "email", "johndoe@example.com")
        # We have no user johndoe in the site
        self.assertIsNone(pi_api.userprofile.get("janedoe"))
        self.assertIsNone(pi_api.userprofile.get("johndoe"))
        # We call the sync view
        view = api.content.get_view(
            "sync-users", self.portal.profiles, self.request.clone()
        )
        view._sync()
        # and they are created
        self.assertIsNotNone(pi_api.userprofile.get("janedoe"))
        external_user = pi_api.userprofile.get("johndoe")
        self.assertEqual(external_user.email, "johndoe@example.com")
        self.assertEqual(external_user.source, TESTING_PLUGIN_ID)
        self.assertEqual(api.content.get_state(external_user), "enabled")

        # Now we remove the user from the external source
        self.external_source._storage.pop("johndoe")
        # if it has the source filed set to something else than the TESTING_PLUGIN_ID
        # nothing happens if we call again the sync view
        external_user.source = "foo"
        view = api.content.get_view(
            "sync-users", self.portal.profiles, self.request.clone()
        )
        view._sync()
        self.assertEqual(external_user.email, "johndoe@example.com")
        self.assertEqual(external_user.source, "foo")
        self.assertEqual(api.content.get_state(external_user), "enabled")

        # Otherwise it is disabled
        external_user.source = TESTING_PLUGIN_ID
        view = api.content.get_view(
            "sync-users", self.portal.profiles, self.request.clone()
        )
        view._sync()
        self.assertEqual(external_user.email, "DISABLED-johndoe@example.com")
        self.assertEqual(external_user.source, TESTING_PLUGIN_ID)
        self.assertEqual(api.content.get_state(external_user), "disabled")

        # John is re-enabled on the external source
        self._set_foreign_property("johndoe", "email", "johndoe@example.com")

        # by default, this does not have any effect:
        # by default, users can be disabled permanently through quaive
        view = api.content.get_view(
            "sync-users", self.portal.profiles, self.request.clone()
        )
        view._sync()
        self.assertEqual(external_user.email, "DISABLED-johndoe@example.com")
        self.assertEqual(external_user.source, TESTING_PLUGIN_ID)
        self.assertEqual(api.content.get_state(external_user), "disabled")

        # Optionally re-activate disabled users that are present in the external source
        view = api.content.get_view(
            "sync-users", self.portal.profiles, self.request.clone()
        )
        with temporary_registry_record(
            "ploneintranet.userprofile.reactivate_on_sync", True
        ):
            view._sync()
        self.assertEqual(external_user.email, "johndoe@example.com")
        self.assertEqual(external_user.source, TESTING_PLUGIN_ID)
        self.assertEqual(api.content.get_state(external_user), "enabled")


class TestProfileSyncProperties(SyncBaseTestCase):
    def test_sync(self):
        self.login_as_portal_owner()
        external_user = self._create_userprofile(
            "johndoe",
            {"first_name": "John", "last_name": "Doe", "source": TESTING_PLUGIN_ID},
        )
        email = "test@foo.com"
        self._set_foreign_property("johndoe", "email", email)
        view = api.content.get_view("sync", external_user, self.request.clone())
        view._sync()

        self.assertEqual(external_user.email, email)
        brains = api.content.find(UID=external_user.UID())
        self.assertEqual(brains[0]["email"], email)
        self._check_last_sync_set(brains[0].getObject())

    def test_sync_local_user(self):
        self.login_as_portal_owner()
        local_user = self._create_userprofile(
            "janedoe", {"first_name": "Jane", "last_name": "Doe"}
        )
        view = api.content.get_view("sync", local_user, self.request.clone())
        view._sync()
        self.assertFalse(
            hasattr(local_user, "last_sync"), "User should not have been synced"
        )


class TestAllUsersPropertySync(SyncBaseTestCase):
    def test_sync(self):
        self.login_as_portal_owner()
        local_user = self._create_userprofile(
            "janedoe", {"first_name": "Jane", "last_name": "Doe"}
        )
        external_user = self._create_userprofile(
            "johndoe",
            {"first_name": "John", "last_name": "Doe", "source": TESTING_PLUGIN_ID},
        )
        email = "test@foo.com"
        self._set_foreign_property("janedoe", "email", email)
        self._set_foreign_property("johndoe", "email", email)
        view = api.content.get_view(
            "sync-user-properties", self.portal.profiles, self.request.clone()
        )
        view._sync()

        # The local user is not synced, the external_one is
        self.assertEqual(local_user.email, "janedoe@example.com")
        self.assertEqual(external_user.email, email)

        # The change is also present in the catalog
        brains = api.content.find(UID=external_user.UID())
        self.assertEqual(brains[0]["email"], email)
        self._check_last_sync_set(brains[0].getObject())

    def test__call__(self):
        self.login_as_portal_owner()
        user = self._create_userprofile(
            "johndoe",
            {"first_name": "John", "last_name": "Doe", "source": TESTING_PLUGIN_ID},
        )
        self.assertEqual(user.email, "johndoe@example.com")
        view = api.content.get_view(
            "sync-user-properties", self.profiles, self.request.clone()
        )
        view()
        self._check_last_sync_set(self.profiles)

        # The email was not changed because there is nothing in the external source
        self.assertEqual(user.email, "johndoe@example.com")

        # Now we try again ...
        view.request.__annotations__.clear()
        self._set_foreign_property(user.getId(), "email", "foo@bar.com")
        view()
        # ... and the email is updated
        self.assertEqual(user.email, "foo@bar.com")

    def test_error(self):
        self.login_as_portal_owner()
        self._create_userprofile(
            "johndoe",
            {"first_name": "John", "last_name": "Doe", "source": TESTING_PLUGIN_ID},
        )
        view = api.content.get_view(
            "sync-user-properties", self.profiles, self.request.clone()
        )

        with mock.patch.object(SyncProperties, "_sync", side_effect=Exception("Error")):
            with self.assertRaises(Exception):
                view()
        self.assertFalse(hasattr(self.profiles, "last_sync"))


class TestAllUsersSync(SyncBaseTestCase):
    def _create_external_user(self, test_user_suffix):
        """Create some usernames that we can use in the tests
        We also want to add an email property in the extenal source
        """
        username = f"{TEST_USER_PREFIX}-{test_user_suffix}"
        email = f"{username}@ploneintranet.org"
        # Email will be overridden after the sync
        self._set_foreign_property(username, "email", email)
        return username

    def _create_external_users(self, n):
        for i in range(n):
            self._create_external_user(i)

    def _check_profiles_synced(self, expected_userids, since=datetime.min):
        for userid in expected_userids:
            self.assertIn(userid, self.profiles)
            profile = self.profiles[userid]
            self._check_last_sync_set(profile)
            self.assertGreater(profile.last_sync, since)

    def _check_sync_dates(self):
        """We want all the profiles that:
        - have been synced
        - are not disabled
        """
        synced_profiles = [
            profile
            for profile in pi_api.userprofile.get_users()
            if (api.content.get_state(obj=profile) != "disabled" and profile.last_sync)
        ]
        self.assertTrue(
            all(
                self.profiles.last_sync > profile.last_sync
                for profile in synced_profiles
            )
        )

    def _call_view_under_test(self):
        view = api.content.get_view("sync-users", self.profiles, self.request.clone())
        view()

    def test_no_profiles_synced(self):
        self._create_external_users(10)
        self._check_profiles_synced([])
        sync_dt = datetime.utcnow()
        self._call_view_under_test()
        member_ids = self._get_member_ids()
        self._check_profiles_synced(member_ids, sync_dt)
        self._check_sync_dates()

    def test_some_profiles_synced(self):
        self._create_external_users(9)

        # Simulate 1 profile already synced
        username = f"{TEST_USER_PREFIX}-9"
        profile = pi_api.userprofile.create(username=username, approve=True)
        api.content.get_view("sync", profile, self.request).record_last_sync()
        self._check_profiles_synced({profile.getId()})

        self._call_view_under_test()
        self._check_profiles_synced(self._get_member_ids())
        self._check_sync_dates()

    def test_all_profiles_synced(self):
        for n in range(2):
            username = f"{TEST_USER_PREFIX}-{n}"
            profile = pi_api.userprofile.create(username=username, approve=True)
            api.content.get_view("sync", profile, self.request).record_last_sync()

        # The sync will raise an exception if the external source has no content
        # (simulates LDAP connection errors)
        with self.assertRaises(RuntimeError):
            self._call_view_under_test()

        # Simulate a new remote user being added
        sync_dt = datetime.utcnow()
        userid = self._create_external_user(3)
        self._call_view_under_test()
        self._check_profiles_synced({userid}, since=sync_dt)
        self._check_sync_dates()

    def test_error_during_sync(self):
        self._create_external_users(2)
        self._check_profiles_synced([])

        with mock.patch.object(SyncProperties, "_sync", side_effect=Exception("Error")):
            with self.assertRaises(Exception):
                self._call_view_under_test()
        self.assertFalse(hasattr(self.profiles, "last_sync"))

    def test_local_user_not_disabled_profile(self):
        """Test that a local user will not be disabled."""
        profile = pi_api.userprofile.create(username="foo", approve=True)
        self.assertEqual(api.content.get_state(obj=profile), "enabled")

        # We need to have at least one user to not make the sync fail
        self._create_external_user("dummy")
        self._call_view_under_test()

        self.assertEqual(api.content.get_state(obj=profile), "enabled")

    def test_external_user_removed_disables_profile(self):
        """Test the case where a remote user been deleted.

        The local membrane profile should be disabled.
        """
        profile = pi_api.userprofile.create(
            username="foo", approve=True, properties={"source": TESTING_PLUGIN_ID}
        )
        self.assertEqual(api.content.get_state(obj=profile), "enabled")

        # We need to have at least one user to not make the sync fail
        self._create_external_user("dummy")
        self._call_view_under_test()

        self.assertEqual(api.content.get_state(obj=profile), "disabled")
        self._check_sync_dates()

    def test_external_user_reactivate(self):
        """Test the case where a remote user has been restored

        The local membrane profile should only be reactivated if the
        appropriate registry record is set.
        """
        profile = pi_api.userprofile.create(
            username="sync-user-foo",
            approve=True,
            properties={"source": TESTING_PLUGIN_ID},
        )
        profile.last_sync = None
        self.assertEqual(api.content.get_state(obj=profile), "enabled")
        # The user gets disabled (through Quaive, or in the external source)
        api.content.transition(obj=profile, to_state="disabled")

        self._create_external_user("foo")

        # Re-importing the user by default does nothing
        self._call_view_under_test()
        self.assertEqual(api.content.get_state(obj=profile), "disabled")
        self._check_sync_dates()

        # Re-activate when the registry record is set
        with temporary_registry_record(
            "ploneintranet.userprofile.reactivate_on_sync", True
        ):
            self._call_view_under_test()
        self.assertEqual(api.content.get_state(obj=profile), "enabled")
        self._check_sync_dates()


class TestGroupSyncCreation(SyncBaseTestCase):
    def test_sync_with_empty_plugin_raises(self):
        self.external_source._groups.clear()
        view = api.content.get_view(
            "sync-groups", self.portal.groups, self.request.clone()
        )
        with self.assertRaises(RuntimeError):
            view._sync()

    def test_sync_creates_missing_users(self):
        self.assertIsNone(pi_api.workgroup.get("queen"))
        # We call the sync view
        self.external_source._groups["queen"] = {}
        view = api.content.get_view(
            "sync-groups", self.portal.groups, self.request.clone()
        )
        view._sync()
        # and they are created
        group = pi_api.workgroup.get("queen")
        self.assertIsNotNone(group)
        self.assertEqual(group.source, TESTING_PLUGIN_ID)
