/*
 * 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 org.apache.jackrabbit.oak.spi.security.authentication.external.impl.principal;

import java.security.Principal;
import java.text.ParseException;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import javax.jcr.PropertyType;
import javax.jcr.RepositoryException;
import javax.jcr.Value;
import javax.jcr.query.Query;

import com.google.common.base.Predicates;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Iterators;
import com.google.common.collect.Sets;

import org.apache.jackrabbit.api.security.principal.GroupPrincipal;
import org.apache.jackrabbit.api.security.principal.ItemBasedPrincipal;
import org.apache.jackrabbit.api.security.principal.PrincipalManager;
import org.apache.jackrabbit.api.security.user.Authorizable;
import org.apache.jackrabbit.api.security.user.UserManager;
import org.apache.jackrabbit.commons.iterator.AbstractLazyIterator;
import org.apache.jackrabbit.oak.api.PropertyState;
import org.apache.jackrabbit.oak.api.PropertyValue;
import org.apache.jackrabbit.oak.api.QueryEngine;
import org.apache.jackrabbit.oak.api.Result;
import org.apache.jackrabbit.oak.api.ResultRow;
import org.apache.jackrabbit.oak.api.Root;
import org.apache.jackrabbit.oak.api.Tree;
import org.apache.jackrabbit.oak.api.Type;
import org.apache.jackrabbit.oak.namepath.NamePathMapper;
import org.apache.jackrabbit.oak.plugins.memory.PropertyValues;
import org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityRef;
import org.apache.jackrabbit.oak.spi.security.authentication.external.basic.DefaultSyncConfig;
import org.apache.jackrabbit.oak.spi.security.authentication.external.impl.ExternalIdentityConstants;
import org.apache.jackrabbit.oak.spi.security.principal.AclGroupDeprecation;
import org.apache.jackrabbit.oak.spi.security.principal.GroupPrincipals;
import org.apache.jackrabbit.oak.spi.security.principal.PrincipalImpl;
import org.apache.jackrabbit.oak.spi.security.principal.PrincipalProvider;
import org.apache.jackrabbit.oak.spi.security.user.AuthorizableType;
import org.apache.jackrabbit.oak.spi.security.user.UserConfiguration;
import org.apache.jackrabbit.oak.spi.security.user.util.UserUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Implementation of the {@code PrincipalProvider} interface that exposes
 * 'external' principals of type {@link org.apache.jackrabbit.api.security.principal.GroupPrincipal}. 'External'
 * refers to the fact that these principals are defined and managed by an
 * {@link org.apache.jackrabbit.oak.spi.security.authentication.external.ExternalIdentityProvider}.
 *
 * For performance reasons this implementation doesn't lookup principals on the IDP
 * but relies on a persisted cache inside the repository where the names of these
 * external principals are synchronized to based on a configurable expiration time.
 *
 * Currently, the implementation respects the {@code rep:externalPrincipalNames}
 * properties, where group membership of external users gets synchronized if
 * {@link DefaultSyncConfig.User#getDynamicMembership() dynamic membership} has
 * been enabled.
 *
 * Please note that in contrast to the default principal provider implementation
 * shipped with Oak the group principals known and exposed by this provider are
 * not backed by an authorizable group and thus cannot be retrieved using
 * Jackrabbit user management API.
 *
 * @since Oak 1.5.3
 * @see org.apache.jackrabbit.oak.spi.security.authentication.external.impl.DynamicSyncContext
 */
class ExternalGroupPrincipalProvider implements PrincipalProvider, ExternalIdentityConstants {

    private static final Logger log = LoggerFactory.getLogger(ExternalGroupPrincipalProvider.class);

    private static final String BINDING_PRINCIPAL_NAMES = "principalNames";

    private final Root root;
    private final NamePathMapper namePathMapper;

    private final UserManager userManager;
    private final AutoMembershipPrincipals autoMembershipPrincipals;

    ExternalGroupPrincipalProvider(@NotNull Root root, @NotNull UserConfiguration uc,
                                   @NotNull NamePathMapper namePathMapper,
                                   @NotNull Map<String, String[]> autoMembershipMapping) {
        this.root = root;
        this.namePathMapper = namePathMapper;

        userManager = uc.getUserManager(root, namePathMapper);
        autoMembershipPrincipals = new AutoMembershipPrincipals(autoMembershipMapping);
    }

    //--------------------------------------------------< PrincipalProvider >---
    @Override
    public Principal getPrincipal(@NotNull String principalName) {
        Result result = findPrincipals(principalName, true);
        if (result != null && result.getRows().iterator().hasNext()) {
            return new ExternalGroupPrincipal(principalName);
        } else {
            return null;
        }
    }

    @NotNull
    @Override
    public Set<Principal> getMembershipPrincipals(@NotNull Principal principal) {
        if (!GroupPrincipals.isGroup(principal)) {
            try {
                if (principal instanceof ItemBasedPrincipal) {
                    Tree t = root.getTree(((ItemBasedPrincipal) principal).getPath());
                    return getGroupPrincipals(t);
                } else {
                    return getGroupPrincipals(userManager.getAuthorizable(principal));
                }
            } catch (RepositoryException e) {
                log.debug(e.getMessage());
            }

        }
        return ImmutableSet.of();
    }

    @NotNull
    @Override
    public Set<? extends Principal> getPrincipals(@NotNull String userID) {
        try {
            return getGroupPrincipals(userManager.getAuthorizable(userID));
        } catch (RepositoryException e) {
            log.debug(e.getMessage());
            return ImmutableSet.of();
        }
    }

    @NotNull
    @Override
    public Iterator<? extends Principal> findPrincipals(@Nullable String nameHint, int searchType) {
        if (PrincipalManager.SEARCH_TYPE_NOT_GROUP != searchType) {
            Result result = findPrincipals(Strings.nullToEmpty(nameHint), false);
            if (result != null) {
                return Iterators.filter(new GroupPrincipalIterator(nameHint, result), Predicates.notNull());
            }
        }

        return Collections.emptyIterator();
    }

    @NotNull
    @Override
    public Iterator<? extends Principal> findPrincipals(int searchType) {
        return findPrincipals(null, searchType);
    }

    @NotNull
    @Override
    public Iterator<? extends Principal> findPrincipals(@Nullable String nameHint, boolean fullText, int searchType,
            long offset, long limit) {
        Iterator<? extends Principal> principals = findPrincipals(nameHint, searchType);
        if (!principals.hasNext()) {
            return Collections.emptyIterator();
        }

        Spliterator<? extends Principal> spliterator = Spliterators.spliteratorUnknownSize(principals, 0);
        Stream<? extends Principal> stream = StreamSupport.stream(spliterator, false);
        stream = stream.sorted(Comparator.comparing(Principal::getName));
        if (offset > 0) {
            stream = stream.skip(offset);
        }
        if (limit >= 0) {
            stream = stream.limit(limit);
        }
        return stream.iterator();
    }

    //------------------------------------------------------------< private >---
    @Nullable
    private String getIdpName(@NotNull Tree userTree) {
        PropertyState ps = userTree.getProperty(REP_EXTERNAL_ID);
        if (ps != null) {
            return ExternalIdentityRef.fromString(ps.getValue(Type.STRING)).getProviderName();
        } else {
            return null;
        }
    }

    private Set<Principal> getGroupPrincipals(@Nullable Authorizable authorizable) throws RepositoryException {
        if (authorizable != null && !authorizable.isGroup()) {
            Tree userTree = root.getTree(authorizable.getPath());
            return getGroupPrincipals(userTree);
        } else {
            return ImmutableSet.of();
        }
    }

    private Set<Principal> getGroupPrincipals(@NotNull Tree userTree) {
        if (userTree.exists() && UserUtil.isType(userTree, AuthorizableType.USER)) {
            PropertyState ps = userTree.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES);
            if (ps != null) {
                // we have an 'external' user that has been synchronized with the dynamic-membership option
                Set<Principal> groupPrincipals = Sets.newHashSet();
                for (String principalName : ps.getValue(Type.STRINGS)) {
                    groupPrincipals.add(new ExternalGroupPrincipal(principalName));
                }

                // add existing group principals as defined with the _autoMembership_ option.
                groupPrincipals.addAll(autoMembershipPrincipals.get(getIdpName(userTree)));
                return groupPrincipals;
            }
        }
        // group principals cannot be retrieved
        return ImmutableSet.of();
    }

    /**
     * Runs an Oak query searching for {@link #REP_EXTERNAL_PRINCIPAL_NAMES} properties
     * that match the given name or name hint.
     *
     * NOTE: ignore any principals listed in the {@link DefaultSyncConfig.User#autoMembership}
     * because they are expected to exist in the system and thus will be found
     * by another principal provider instance.
     *
     * @param nameHint The principal name or name hint to be searched for.
     * @param exactMatch boolean flag indicating if the query should search for
     *                   exact matching.
     * @return The query result.
     */
    @Nullable
    private Result findPrincipals(@NotNull String nameHint, boolean exactMatch) {
        try {
            Map<String, ? extends PropertyValue> bindings = buildBinding(nameHint, exactMatch);
            String op = (exactMatch) ? " = " : " LIKE ";
            String statement = "SELECT '" + REP_EXTERNAL_PRINCIPAL_NAMES + "' FROM [rep:User] WHERE PROPERTY(["
                    + REP_EXTERNAL_PRINCIPAL_NAMES + "], '" + PropertyType.TYPENAME_STRING + "')"
                    + op + "$" + BINDING_PRINCIPAL_NAMES + QueryEngine.INTERNAL_SQL2_QUERY;
            return root.getQueryEngine().executeQuery(statement, Query.JCR_SQL2, bindings, namePathMapper.getSessionLocalMappings());
        } catch (ParseException e) {
            return null;
        }
    }

    /**
     * Build the map used for the query bindings.
     *
     * @param nameHint The name hint
     * @param exactMatch boolean flag indicating if the query should search for exact matching.
     * @return the bindings
     */
    @NotNull
    private static Map<String, ? extends PropertyValue> buildBinding(@NotNull String nameHint, boolean exactMatch) {
        String val = nameHint;
        if (!exactMatch) {
            // not-exact query matching required => add leading and trailing %
            if (nameHint.isEmpty()) {
                val = "%";
            } else {
                StringBuilder sb = new StringBuilder();
                sb.append('%');
                sb.append(nameHint.replace("%", "\\%").replace("_", "\\_"));
                sb.append('%');
                val = sb.toString();
            }
        }
        return Collections.singletonMap(BINDING_PRINCIPAL_NAMES, PropertyValues.newString(val));
    }

    //------------------------------------------------------< inner classes >---

    /**
     * Implementation of the {@link org.apache.jackrabbit.api.security.principal.GroupPrincipal} interface representing external group
     * identities that are <strong>not</strong> represented as authorizable group
     * in the repository's user management.
     */
    private final class ExternalGroupPrincipal extends PrincipalImpl implements GroupPrincipal, java.security.acl.Group {

        private ExternalGroupPrincipal(String principalName) {
            super(principalName);

        }

        @Override
        public boolean addMember(Principal user) {
            AclGroupDeprecation.handleCall();
            if (isMember(user)) {
                return false;
            } else {
                throw new UnsupportedOperationException("Adding members to external group principals is not supported.");
            }
        }

        @Override
        public boolean removeMember(Principal user) {
            AclGroupDeprecation.handleCall();
            if (!isMember(user)) {
                return false;
            } else {
                throw new UnsupportedOperationException("Removing members from external group principals is not supported.");
            }
        }

        @Override
        public boolean isMember(@NotNull Principal member) {
            if (GroupPrincipals.isGroup(member)) {
                return false;
            }
            try {
                String name = getName();
                if (member instanceof ItemBasedPrincipal) {
                    Tree tree = root.getTree(((ItemBasedPrincipal) member).getPath());
                    if (UserUtil.isType(tree, AuthorizableType.USER)) {
                        PropertyState ps = tree.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES);
                        return (ps != null && Iterables.contains(ps.getValue(Type.STRINGS), name));
                    }
                } else {
                    Authorizable a = userManager.getAuthorizable(member);
                    if (a != null && !a.isGroup()) {
                        Value[] vs = a.getProperty(REP_EXTERNAL_PRINCIPAL_NAMES);
                        if (vs != null) {
                            for (Value v : vs) {
                                if (name.equals(v.getString())) {
                                    return true;
                                }
                            }
                        }
                    }
                }
            } catch (RepositoryException e) {
                log.debug(e.getMessage());
            }
            return false;
        }

        @NotNull
        @Override
        public Enumeration<? extends Principal> members() {
            Result result = findPrincipals(getName(), true);
            if (result != null) {
                return Iterators.asEnumeration(new MemberIterator(result));
            } else {
                return Iterators.asEnumeration(Collections.<Principal>emptyIterator());
            }
        }
    }

    /**
     * Principal iterator converting the query results of
     * {@link #findPrincipals(String, int)} and {@link #findPrincipals(int)}.
     * Since each result row provides the values of the {@code PropertyState},
     * which matched the query, this iterator needs to filter the individual
     * property values.
     *
     * Additional the iterator keeps track of principal names that have already
     * been served and will not return duplicates.
     *
     * @see #findPrincipals(String, int)
     * @see #findPrincipals(int)
     */
    private final class GroupPrincipalIterator extends AbstractLazyIterator<Principal> {

        private final Set<String> processed = new HashSet<>();

        private final String queryString;
        private final Iterator<? extends ResultRow> rows;

        private Iterator<String> propValues = Collections.emptyIterator();

        private GroupPrincipalIterator(@Nullable String queryString, @NotNull Result queryResult) {
            this.queryString = queryString;
            rows = queryResult.getRows().iterator();
        }

        @Override
        protected Principal getNext() {
            if (!propValues.hasNext()) {
                if (rows.hasNext()) {
                    propValues = Iterators.filter(rows.next().getValue(REP_EXTERNAL_PRINCIPAL_NAMES).getValue(Type.STRINGS).iterator(), Predicates.notNull());
                } else {
                    propValues = Collections.emptyIterator();
                }
            }
            while (propValues.hasNext()) {
                String principalName = propValues.next();
                if (!processed.contains(principalName) && matchesQuery(principalName) ) {
                    processed.add(principalName);
                    return new ExternalGroupPrincipal(principalName);
                }
            }
            return null;
        }

        private boolean matchesQuery(@NotNull String principalName) {
            if (queryString == null) {
                return true;
            } else {
                return principalName.contains(queryString);
            }
        }
    }

    /**
     * {@code Principal} iterator representing the members of a given
     * {@link ExternalGroupPrincipal}. The members are collected through an
     * Oak {@link org.apache.jackrabbit.oak.query.Query Query}.
     *
     * Note that the query result is subject to permission evaluation for
     * the editing {@link Root} based on the accessibility of the individual
     * {@link #REP_EXTERNAL_PRINCIPAL_NAMES} properties that contain the
     * exact name of the external group principal.
     *
     * @see ExternalGroupPrincipal#members()
     */
    private final class MemberIterator extends AbstractLazyIterator<Principal> {

        /**
         * The query results containing the path of the user accounts
         * (i.e. members) that contain the target group principal in the
         * {@link #REP_EXTERNAL_PRINCIPAL_NAMES} property values.
         */
        private final Iterator<? extends ResultRow> rows;

        private MemberIterator(@NotNull Result queryResult) {
            rows = queryResult.getRows().iterator();
        }

        @Override
        protected Principal getNext() {
            while (rows.hasNext()) {
                String userPath = rows.next().getPath();
                try {
                    Authorizable authorizable = userManager.getAuthorizableByPath(userPath);
                    if (authorizable != null) {
                        return authorizable.getPrincipal();
                    }
                } catch (RepositoryException e) {
                    log.debug("{}", e.getMessage());
                }
            }
            return null;
        }
    }

    private final class AutoMembershipPrincipals {

        private final Map<String, String[]> autoMembershipMapping;
        private final Map<String, Set<Principal>> principalMap;

        private AutoMembershipPrincipals(@NotNull Map<String, String[]> autoMembershipMapping) {
            this.autoMembershipMapping = autoMembershipMapping;
            this.principalMap = new ConcurrentHashMap<>(autoMembershipMapping.size());
        }

        @NotNull
        private Collection<Principal> get(@Nullable String idpName) {
            if (idpName == null) {
                return ImmutableSet.of();
            }

            Set<Principal> principals;
            if (!principalMap.containsKey(idpName)) {
                String[] vs = autoMembershipMapping.get(idpName);
                if (vs == null) {
                    principals = ImmutableSet.of();
                } else {
                    ImmutableSet.Builder<Principal> builder = ImmutableSet.builder();
                    for (String groupId : autoMembershipMapping.get(idpName)) {
                        try {
                            Authorizable gr = userManager.getAuthorizable(groupId);
                            if (gr != null && gr.isGroup()) {
                                Principal grPrincipal = gr.getPrincipal();
                                if (GroupPrincipals.isGroup(grPrincipal)) {
                                    builder.add(grPrincipal);
                                } else {
                                    log.warn("Principal of group {} is not of group type -> Ignoring", groupId);
                                }
                            } else {
                                log.warn("Configured auto-membership group {} does not exist -> Ignoring", groupId);
                            }
                        } catch (RepositoryException e) {
                            log.debug("Failed to retrieved 'auto-membership' group with id {}", groupId, e);
                        }
                    }
                    principals = builder.build();
                }
                principalMap.put(idpName, principals);
            } else {
                principals = principalMap.get(idpName);
            }
            return principals;
        }
    }
}
