/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package de.tudarmstadt.hrz.idm.oss.custom.persistance.jpa;

import de.tudarmstadt.hrz.idm.oss.custom.event.custom.GroupMembershipChangeEvent;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.persistence.Query;

import de.tudarmstadt.hrz.idm.oss.access.AccessException;
import de.tudarmstadt.hrz.idm.oss.custom.core.provisioning.notification.GroupNotificationSender;
import de.tudarmstadt.hrz.idm.oss.schema.DeserializationException;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.syncope.common.lib.SyncopeConstants;
import org.apache.syncope.common.lib.types.AnyTypeKind;
import org.apache.syncope.core.persistence.api.dao.AnyDAO;
import org.apache.syncope.core.persistence.api.dao.AnyMatchDAO;
import org.apache.syncope.core.persistence.api.dao.AnyObjectDAO;
import org.apache.syncope.core.persistence.api.dao.AnySearchDAO;
import org.apache.syncope.core.persistence.api.dao.DerSchemaDAO;
import org.apache.syncope.core.persistence.api.dao.DynRealmDAO;
import org.apache.syncope.core.persistence.api.dao.JPAJSONAnyDAO;
import org.apache.syncope.core.persistence.api.dao.PlainAttrDAO;
import org.apache.syncope.core.persistence.api.dao.PlainSchemaDAO;
import org.apache.syncope.core.persistence.api.dao.RealmDAO;
import org.apache.syncope.core.persistence.api.dao.UserDAO;
import org.apache.syncope.core.persistence.api.dao.search.SearchCond;
import org.apache.syncope.core.persistence.api.entity.AnyUtilsFactory;
import org.apache.syncope.core.persistence.api.entity.ExternalResource;
import org.apache.syncope.core.persistence.api.entity.anyobject.AnyObject;
import org.apache.syncope.core.persistence.api.entity.group.Group;
import org.apache.syncope.core.persistence.api.entity.user.UDynGroupMembership;
import org.apache.syncope.core.persistence.api.entity.user.User;
import org.apache.syncope.core.persistence.api.search.SearchCondVisitor;
import org.apache.syncope.core.persistence.jpa.dao.JPAJSONGroupDAO;
import org.apache.syncope.core.provisioning.api.event.EntityLifecycleEvent;
import org.apache.syncope.core.spring.security.AuthContextUtils;
import org.identityconnectors.framework.common.objects.SyncDeltaType;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.annotation.Transactional;

import static de.tudarmstadt.hrz.idm.oss.custom.util.GroupUtils.getModificationDataOfGroup;

public class CustomJPAJSONGroupDAO extends JPAJSONGroupDAO {

    private final GroupNotificationSender groupNotificationSender;

    public CustomJPAJSONGroupDAO(
            final AnyUtilsFactory anyUtilsFactory,
            final ApplicationEventPublisher publisher,
            final PlainSchemaDAO plainSchemaDAO,
            final DerSchemaDAO derSchemaDAO,
            final DynRealmDAO dynRealmDAO,
            final RealmDAO realmDAO,
            final AnyMatchDAO anyMatchDAO,
            final PlainAttrDAO plainAttrDAO,
            final UserDAO userDAO,
            final AnyObjectDAO anyObjectDAO,
            final AnySearchDAO searchDAO,
            final SearchCondVisitor searchCondVisitor,
            final JPAJSONAnyDAO anyDAO,
            final GroupNotificationSender groupNotificationSender) {

        super(anyUtilsFactory,
                publisher,
                plainSchemaDAO,
                derSchemaDAO,
                dynRealmDAO,
                realmDAO,
                anyMatchDAO,
                plainAttrDAO,
                userDAO,
                anyObjectDAO,
                searchDAO,
                searchCondVisitor,
                anyDAO);
        this.groupNotificationSender = groupNotificationSender;
        LOG.info ("using custom group data access object");
    }

    // simplified from AbstractAnySearchDAO.check(MembershipCond)
    private List<String> resolveGroupExpression(final String group) {
        List<String> groups = SyncopeConstants.UUID_PATTERN.matcher(group).matches()
            ? List.of(group)
            : group.indexOf('%') == -1
                ? Optional.ofNullable(findKey(group)).map(List::of).orElseGet(List::of)
                : findKeysByNamePattern(group);

        if (groups.isEmpty()) {
            throw new IllegalArgumentException("Could not find group(s) for " + group);
        }

        return groups;
    }

    /**
     * Handle membership change of user
     *
     * @param user          the user
     * @param addedGroups   the groups the user was added to
     * @param removedGroups the group the user was removed from
     */
    private void membershipChanged(
        final User user,
        final List<Group> addedGroups,
        final List<Group> removedGroups
    ) {
        if (addedGroups.isEmpty() && removedGroups.isEmpty()) {
            return;
        }
        final String userKey = user.getKey();
        LOG.trace("notify about membership changes of user {}", userKey);
        publisher.publishEvent(
            new GroupMembershipChangeEvent(
                userKey,
                user.getResources().stream().map(ExternalResource::getKey).toList(),
                addedGroups.stream().map(Group::getKey).toList(),
                removedGroups.stream().map(Group::getKey).toList(),
                getClass().getSimpleName()
            )
        );
    }

    @Transactional
    public Pair<Set<String>, Set<String>> refreshDynMemberships(final User user, final Group modifiedGroup) {
        Set<String> before = new HashSet<>();
        Set<String> after = new HashSet<>();
        var addMemberNotifications = new ArrayList<Group>();
        var removeMemberNotifications = new ArrayList<Group>();
        final var uDynGroupMembershipList = findWithUDynMemberships();
        final var groupKeyToFiqlCond = uDynGroupMembershipList.stream().collect(
            Collectors.toMap(
                uDynGroupMembership -> uDynGroupMembership.getGroup().getKey(),
                uDynGroupMembership -> uDynGroupMembership.getFIQLCond()
            )
        );
        final var matcher = new GroupMembershipMatcher(
            groupKeyToFiqlCond::get,
            this::buildDynMembershipCond,
            this::resolveGroupExpression,
            cond -> anyMatchDAO.matches(user, cond),
            groupKey -> user.getMembership(groupKey).isPresent()
        );

        final Query query = entityManager().createNativeQuery(
            "SELECT group_id FROM " + UDYNMEMB_TABLE + " WHERE any_id=?"
        );
        query.setParameter(1, user.getKey());
        final Set<String> dynGroupsOfUser = ((Stream<?>) query.getResultStream()).map(
            String.class::cast
        ).collect(Collectors.toSet());

        uDynGroupMembershipList.forEach(memb -> {
            final Boolean matches = matcher.matches(memb.getGroup().getKey());
            if (null == matches) {
                // no definite result, so do not change membership for this group
                return;
            }
            if (matches) {
                after.add(memb.getGroup().getKey());
            }

            boolean existing = dynGroupsOfUser.contains(memb.getGroup().getKey());
            if (existing) {
                before.add(memb.getGroup().getKey());
            }

            if (matches && !existing) {
                Query insert = entityManager().createNativeQuery(
                        "INSERT INTO " + UDYNMEMB_TABLE + " VALUES(?, ?)");
                insert.setParameter(1, user.getKey());
                insert.setParameter(2, memb.getGroup().getKey());
                insert.executeUpdate();
                publisher.publishEvent(new EntityLifecycleEvent<>(
                        this, SyncDeltaType.UPDATE, memb.getGroup(), AuthContextUtils.getDomain()));
                if (user.getMemberships().stream().noneMatch(
                        membership -> membership.getRightEnd().getName().equals(memb.getGroup().getName()))) {
                    addMemberNotifications.add(memb.getGroup());
                }
            } else if (!matches && existing) {
                Query delete = entityManager().createNativeQuery(
                        "DELETE FROM " + UDYNMEMB_TABLE + " WHERE group_id=? AND any_id=?");
                delete.setParameter(1, memb.getGroup().getKey());
                delete.setParameter(2, user.getKey());
                delete.executeUpdate();
                publisher.publishEvent(new EntityLifecycleEvent<>(
                        this, SyncDeltaType.UPDATE, memb.getGroup(), AuthContextUtils.getDomain()));
                if (user.getMemberships().stream().noneMatch(
                        membership -> membership.getRightEnd().getName().equals(memb.getGroup().getName()))) {
                    removeMemberNotifications.add(memb.getGroup());
                }
            }

        });

        AbstractMap.SimpleEntry<String, String> modificationData = null;
        if (modifiedGroup != null) {
            try {
                modificationData = getModificationDataOfGroup(modifiedGroup);
            } catch (AccessException | DeserializationException e) {
                LOG.error("Error while getting modification data for group {}", modifiedGroup.getName(), e);
            }
        }

        groupNotificationSender.sendMembershipChangedNotification(user, true, addMemberNotifications, modificationData);
        groupNotificationSender.sendMembershipChangedNotification(user, false, removeMemberNotifications, modificationData);

        membershipChanged(
            user,
            addMemberNotifications,
            removeMemberNotifications
        );

        return Pair.of(before, after);
    }

    @Transactional
    @Override
    public Pair<Set<String>, Set<String>> refreshDynMemberships(final User user) {
        return refreshDynMemberships(user, null);
    }

    @SuppressWarnings("unchecked")
    private List<String> findUDynMembersFromStorage(final Group group) {
        Query query = entityManager().createNativeQuery(
            "SELECT DISTINCT any_id FROM " + UDYNMEMB_TABLE + " WHERE group_id=?");
        query.setParameter(1, group.getKey());

        List<String> result = new ArrayList<>();
        query.getResultList().stream().map(key -> key instanceof Object[]
                ? (String) ((Object[]) key)[0]
                : ((String) key)).
            forEach(user -> result.add((String) user));
        return result;
    }

    @Override
    public Group saveAndRefreshDynMemberships(final Group group) {
        Group merged = save(group);

        // refresh dynamic memberships

        final List<String> oldMemberList = findUDynMembersFromStorage(merged);
        final UDynGroupMembership uDynMembership = merged.getUDynMembership();
        final Collection<String> oldMemberCollection;
        final boolean isNotDynamicGroup;
        if ((null == uDynMembership) || (null == uDynMembership.getFIQLCond()) || uDynMembership.getFIQLCond().isEmpty()) {
            clearUDynMembers(merged);
            oldMemberCollection = oldMemberList;
            isNotDynamicGroup = true;
        } else {
            oldMemberCollection = new HashSet<>(oldMemberList);
            isNotDynamicGroup = false;
            SearchCond cond = buildDynMembershipCond(merged.getUDynMembership().getFIQLCond());
            int count = anySearchDAO.count(
                    realmDAO.getRoot(), true, Set.of(SyncopeConstants.ROOT_REALM), cond, AnyTypeKind.USER);
            for (int page = 1; page <= (count / AnyDAO.DEFAULT_PAGE_SIZE) + 1; page++) {
                List<User> matching = anySearchDAO.search(
                        realmDAO.getRoot(),
                        true,
                        Set.of(SyncopeConstants.ROOT_REALM),
                        cond,
                        page,
                        AnyDAO.DEFAULT_PAGE_SIZE,
                        List.of(),
                        AnyTypeKind.USER);

                matching.forEach(user -> {
                    if (oldMemberCollection.remove(user.getKey())) {
                        return;
                    }
                    // this is now a member (but was not before)
                    refreshDynMemberships(user, group);
                    publisher.publishEvent(
                            new EntityLifecycleEvent<>(this, SyncDeltaType.UPDATE, user, AuthContextUtils.getDomain()));
                });
            }
        }
        // at this point, current members are removed from the old member set, so remove those remaining
        AbstractMap.SimpleEntry<String, String> modificationData = null;
        if (isNotDynamicGroup && !oldMemberCollection.isEmpty()) {
            try {
                modificationData = getModificationDataOfGroup(group);
            } catch (AccessException | DeserializationException e) {
                LOG.error("Error while getting modification data for group {}", group.getName(), e);
            }
        }
        for (final String oldMember : oldMemberCollection) {
            final User user = userDAO.find(oldMember);
            if (null == user) {
                LOG.warn("could not find user " + oldMember);
                continue;
            }
            // this is no longer a member (but was before)
            refreshDynMemberships(user, group);
            publisher.publishEvent(
                new EntityLifecycleEvent<>(
                    this,
                    SyncDeltaType.UPDATE,
                    user,
                    AuthContextUtils.getDomain()
                )
            );
            if (isNotDynamicGroup) {
                // group is (no longer) dynamic but had user as dynamic member
                final boolean isNotStaticMember = user.getMemberships().stream().noneMatch(
                    membership -> membership.getRightEnd().getName().equals(group.getName())
                );
                if (isNotStaticMember) {
                    // user is no static member, either, so is no longer member at all
                    groupNotificationSender.sendMembershipChangedNotification(
                        user,
                        false,
                        List.of(group),
                        modificationData
                    );
                    membershipChanged(user, List.of(), List.of(group));
                }
            }
        }

        clearADynMembers(merged);
        merged.getADynMemberships().forEach(memb -> {
            SearchCond cond = buildDynMembershipCond(memb.getFIQLCond());
            int count = anySearchDAO.count(
                    merged.getRealm(), true, Set.of(merged.getRealm().getFullPath()), cond, AnyTypeKind.ANY_OBJECT);
            for (int page = 1; page <= (count / AnyDAO.DEFAULT_PAGE_SIZE) + 1; page++) {
                List<AnyObject> matching = anySearchDAO.search(
                        realmDAO.getRoot(),
                        true,
                        Set.of(SyncopeConstants.ROOT_REALM),
                        cond,
                        page,
                        AnyDAO.DEFAULT_PAGE_SIZE,
                        List.of(),
                        AnyTypeKind.ANY_OBJECT);

                matching.forEach(any -> {
                    Query insert = entityManager().createNativeQuery(
                            "INSERT INTO " + ADYNMEMB_TABLE + " VALUES(?, ?, ?)");
                    insert.setParameter(1, any.getType().getKey());
                    insert.setParameter(2, any.getKey());
                    insert.setParameter(3, merged.getKey());
                    insert.executeUpdate();

                    publisher.publishEvent(
                            new EntityLifecycleEvent<>(this, SyncDeltaType.UPDATE, any, AuthContextUtils.getDomain()));
                });
            }
        });

        dynRealmDAO.refreshDynMemberships(merged);

        return merged;
    }
}
