/*
 * 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.security.user;

import java.security.Principal;
import java.security.acl.Group;
import java.text.ParseException;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.jcr.AccessDeniedException;
import javax.jcr.RepositoryException;

import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.collect.Iterables;
import com.google.common.collect.Iterators;
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.oak.api.CommitFailedException;
import org.apache.jackrabbit.oak.api.PropertyState;
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.commons.LongUtils;
import org.apache.jackrabbit.oak.namepath.NamePathMapper;
import org.apache.jackrabbit.oak.security.user.query.QueryUtil;
import org.apache.jackrabbit.oak.spi.security.principal.EveryonePrincipal;
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.principal.SystemPrincipal;
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.UserConstants;
import org.apache.jackrabbit.oak.spi.security.user.util.UserUtil;
import org.apache.jackrabbit.oak.util.NodeUtil;
import org.apache.jackrabbit.util.Text;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.apache.jackrabbit.oak.api.QueryEngine.NO_BINDINGS;
import static org.apache.jackrabbit.oak.api.Type.STRING;

/**
 * The {@code PrincipalProviderImpl} is a principal provider implementation
 * that operates on principal information read from user information exposed by
 * the configured {@link org.apache.jackrabbit.api.security.user.UserManager}.
 */
class UserPrincipalProvider implements PrincipalProvider {

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

    static final String PARAM_CACHE_EXPIRATION = "cacheExpiration";
    static final long EXPIRATION_NO_CACHE = 0;

    private static final long MEMBERSHIP_THRESHOLD = 0;

    private final Root root;
    private final UserConfiguration config;
    private final NamePathMapper namePathMapper;

    private final UserProvider userProvider;
    private final MembershipProvider membershipProvider;

    private final long expiration;
    private final boolean cacheEnabled;

    UserPrincipalProvider(@Nonnull Root root,
                          @Nonnull UserConfiguration userConfiguration,
                          @Nonnull NamePathMapper namePathMapper) {
        this.root = root;
        this.config = userConfiguration;
        this.namePathMapper = namePathMapper;

        this.userProvider = new UserProvider(root, config.getParameters());
        this.membershipProvider = new MembershipProvider(root, config.getParameters());

        expiration = config.getParameters().getConfigValue(PARAM_CACHE_EXPIRATION, EXPIRATION_NO_CACHE);
        cacheEnabled = (expiration > EXPIRATION_NO_CACHE && root.getContentSession().getAuthInfo().getPrincipals().contains(SystemPrincipal.INSTANCE));
    }

    //--------------------------------------------------< PrincipalProvider >---
    @Override
    public Principal getPrincipal(@Nonnull String principalName) {
        Tree authorizableTree = userProvider.getAuthorizableByPrincipal(new PrincipalImpl(principalName));
        Principal principal = createPrincipal(authorizableTree);

        if (principal == null) {
            // no such principal or error while accessing principal from user/group
            return (EveryonePrincipal.NAME.equals(principalName)) ? EveryonePrincipal.getInstance() : null;
        } else {
            return principal;
        }
    }

    @Nonnull
    @Override
    public Set<Group> getGroupMembership(@Nonnull Principal principal) {
        Tree tree = getAuthorizableTree(principal);
        if (tree == null) {
            return Collections.emptySet();
        } else {
            return getGroupMembership(tree);
        }
    }

    @Nonnull
    @Override
    public Set<? extends Principal> getPrincipals(@Nonnull String userID) {
        Set<Principal> principals = new HashSet<Principal>();
        Tree tree = userProvider.getAuthorizable(userID);
        if (tree != null && UserUtil.isType(tree, AuthorizableType.USER)) {
            Principal userPrincipal = createUserPrincipal(userID, tree);
            if (userPrincipal != null) {
                principals.add(userPrincipal);
                principals.addAll(getGroupMembership(tree));
            }
        }
        return principals;
    }

    @Nonnull
    @Override
    public Iterator<? extends Principal> findPrincipals(final String nameHint,
                                                        final int searchType) {
        try {
            AuthorizableType type = AuthorizableType.getType(searchType);
            StringBuilder statement = new StringBuilder()
                    .append(QueryUtil.getSearchRoot(type, config.getParameters()))
                    .append("//element(*,").append(QueryUtil.getNodeTypeName(type)).append(')')
                    .append("[jcr:like(@rep:principalName,'")
                    .append(buildSearchPattern(nameHint))
                    .append("')]");

            Result result = root.getQueryEngine().executeQuery(
                    statement.toString(), javax.jcr.query.Query.XPATH,
                    NO_BINDINGS, namePathMapper.getSessionLocalMappings());

            Iterator<Principal> principals = Iterators.filter(
                    Iterators.transform(result.getRows().iterator(), new ResultRowToPrincipal()),
                    Predicates.notNull());

            if (matchesEveryone(nameHint, searchType)) {
                principals = Iterators.concat(principals, Iterators.singletonIterator(EveryonePrincipal.getInstance()));
                return Iterators.filter(principals, new EveryonePredicate());
            } else {
                return principals;
            }
        } catch (ParseException e) {
            log.debug(e.getMessage());
            return Iterators.emptyIterator();
        }
    }

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

    //------------------------------------------------------------< private >---
    @CheckForNull
    private Tree getAuthorizableTree(@Nonnull Principal principal) {
        return userProvider.getAuthorizableByPrincipal(principal);
    }

    @CheckForNull
    private Principal createPrincipal(@CheckForNull Tree authorizableTree) {
        Principal principal = null;
        if (authorizableTree != null) {
            AuthorizableType type = UserUtil.getType(authorizableTree);
            if (AuthorizableType.GROUP == type) {
                principal = createGroupPrincipal(authorizableTree);
            } else if (AuthorizableType.USER == type) {
                principal = createUserPrincipal(UserUtil.getAuthorizableId(authorizableTree, type), authorizableTree);
            }
        }
        return principal;
    }

    @CheckForNull
    private Principal createUserPrincipal(@Nonnull String id, @Nonnull Tree userTree) {
        String principalName = getPrincipalName(userTree);
        if (principalName == null) {
            return null;
        }
        if (UserUtil.isSystemUser(userTree)) {
            return new SystemUserPrincipalImpl(principalName, userTree, namePathMapper);
        } else if (UserUtil.isAdmin(config.getParameters(), id)) {
            return new AdminPrincipalImpl(principalName, userTree, namePathMapper);
        } else {
            return new TreeBasedPrincipal(principalName, userTree, namePathMapper);
        }
    }

    @CheckForNull
    private Group createGroupPrincipal(@Nonnull Tree groupTree) {
        String principalName = getPrincipalName(groupTree);
        if (principalName == null) {
            return null;
        }
        return new GroupPrincipal(principalName, groupTree);
    }

    @CheckForNull
    private static String getPrincipalName(@Nonnull Tree tree) {
        PropertyState principalName = tree.getProperty(UserConstants.REP_PRINCIPAL_NAME);
        if (principalName != null) {
            return principalName.getValue(STRING);
        } else {
            String msg = "Authorizable without principal name " + UserUtil.getAuthorizableId(tree);
            log.warn(msg);
            return null;
        }
    }

    @Nonnull
    private Set<Group> getGroupMembership(@Nonnull Tree authorizableTree) {
        Set<Group> groupPrincipals = null;
        NodeUtil authorizableNode = new NodeUtil(authorizableTree);
        boolean doCache = cacheEnabled && UserUtil.isType(authorizableTree, AuthorizableType.USER);
        if (doCache) {
            groupPrincipals = readGroupsFromCache(authorizableNode);
        }

        // caching not configured or cache expired: use the membershipProvider to calculate
        if (groupPrincipals == null) {
            groupPrincipals = new HashSet<Group>();
            Iterator<String> groupPaths = membershipProvider.getMembership(authorizableTree, true);
            while (groupPaths.hasNext()) {
                Tree groupTree = userProvider.getAuthorizableByPath(groupPaths.next());
                if (groupTree != null && UserUtil.isType(groupTree, AuthorizableType.GROUP)) {
                    Group gr = createGroupPrincipal(groupTree);
                    if (gr != null) {
                        groupPrincipals.add(createGroupPrincipal(groupTree));
                    }
                }
            }

            // remember the regular groups in case caching is enabled
            if (doCache) {
                cacheGroups(authorizableNode, groupPrincipals);
            }
        }

        // add the dynamic everyone principal group which is not included in
        // the 'getMembership' call.
        groupPrincipals.add(EveryonePrincipal.getInstance());
        return groupPrincipals;
    }

    private void cacheGroups(@Nonnull NodeUtil authorizableNode, @Nonnull Set<Group> groupPrincipals) {
        try {
            root.refresh();
            NodeUtil cache = authorizableNode.getChild(CacheConstants.REP_CACHE);
            if (cache == null) {
                if (groupPrincipals.size() <= MEMBERSHIP_THRESHOLD) {
                    log.debug("Omit cache creation for user without group membership at " + authorizableNode.getTree().getPath());
                    return;
                } else {
                    log.debug("Create new group membership cache at " + authorizableNode.getTree().getPath());
                    cache = authorizableNode.addChild(CacheConstants.REP_CACHE, CacheConstants.NT_REP_CACHE);
                }
            }

            cache.setLong(CacheConstants.REP_EXPIRATION, LongUtils.calculateExpirationTime(expiration));
            String value = (groupPrincipals.isEmpty()) ? "" : Joiner.on(",").join(Iterables.transform(groupPrincipals, new Function<Group, String>() {
                @Override
                public String apply(Group input) {
                    return Text.escape(input.getName());
                }
            }));
            cache.setString(CacheConstants.REP_GROUP_PRINCIPAL_NAMES, value);

            root.commit(CacheValidatorProvider.asCommitAttributes());
            log.debug("Cached group membership at " + authorizableNode.getTree().getPath());

        } catch (AccessDeniedException e) {
            log.debug("Failed to cache group membership", e.getMessage());
        } catch (CommitFailedException e) {
            log.debug("Failed to cache group membership", e.getMessage(), e);
        } finally {
            root.refresh();
        }
    }

    @CheckForNull
    private Set<Group> readGroupsFromCache(@Nonnull NodeUtil authorizableNode) {
        NodeUtil principalCache = authorizableNode.getChild(CacheConstants.REP_CACHE);
        if (principalCache == null) {
            log.debug("No group cache at " + authorizableNode.getTree().getPath());
            return null;
        }

        if (isValidCache(principalCache)) {
            log.debug("Reading group membership at " + authorizableNode.getTree().getPath());

            String str = principalCache.getString(CacheConstants.REP_GROUP_PRINCIPAL_NAMES, null);
            if (str == null || str.isEmpty()) {
                return new HashSet<Group>(1);
            }

            Set<Group> groups = new HashSet<Group>();
            for (String s : Text.explode(str, ',')) {
                final String name = Text.unescape(s);
                groups.add(new CachedGroupPrincipal(name));
            }
            return groups;
        } else {
            log.debug("Expired group cache for " + authorizableNode.getTree().getPath());
            return null;
        }
    }

    private static boolean isValidCache(NodeUtil principalCache)  {
        long expirationTime = principalCache.getLong(CacheConstants.REP_EXPIRATION, EXPIRATION_NO_CACHE);
        long now = new Date().getTime();
        return expirationTime > EXPIRATION_NO_CACHE && now < expirationTime;
    }

    private static String buildSearchPattern(String nameHint) {
        if (nameHint == null) {
            return "%";
        } else {
            StringBuilder sb = new StringBuilder();
            sb.append('%');
            sb.append(nameHint.replace("%", "\\%").replace("_", "\\_"));
            sb.append('%');
            return sb.toString();
        }
    }

    private static boolean matchesEveryone(String nameHint, int searchType) {
        return searchType != PrincipalManager.SEARCH_TYPE_NOT_GROUP &&
                (nameHint == null || EveryonePrincipal.NAME.contains(nameHint));
    }

    //--------------------------------------------------------------------------
    /**
     * Function to covert an authorizable tree (as obtained from the query result) to a principal.
     */
    private final class ResultRowToPrincipal implements Function<ResultRow, Principal> {
        @Override
        public Principal apply(@Nullable ResultRow resultRow) {
            return (resultRow != null) ? createPrincipal(resultRow.getTree(null)) : null;
        }
    }

    /**
     * Predicate to make sure the everyone principal is only included once in
     * the result set.
     */
    private static final class EveryonePredicate implements Predicate<Principal> {
        private boolean servedEveryone = false;
        @Override
        public boolean apply(@Nullable Principal principal) {
            String pName = (principal == null) ? null : principal.getName();
            if (EveryonePrincipal.NAME.equals(pName)) {
                if (servedEveryone) {
                    return false;
                } else {
                    servedEveryone = true;
                    return true;
                }
            } else {
                // not everyone
                return true;
            }
        }
    }

    //--------------------------------------------------------------------------
    // Group Principal implementations that retrieve member information on demand
    //--------------------------------------------------------------------------

    private abstract class BaseGroupPrincipal extends AbstractGroupPrincipal {

        private UserManager userManager;

        BaseGroupPrincipal(@Nonnull String principalName, @Nonnull Tree groupTree) {
            super(principalName, groupTree, namePathMapper);
        }

        BaseGroupPrincipal(@Nonnull String principalName, @Nonnull String groupPath) {
            super(principalName, groupPath, namePathMapper);
        }

        @Override
        UserManager getUserManager() {
            if (userManager == null) {
                userManager = config.getUserManager(root, namePathMapper);
            }
            return userManager;
        }

        @Override
        boolean isEveryone() {
            return EveryonePrincipal.NAME.equals(getName());
        }

        @Override
        boolean isMember(@Nonnull Authorizable authorizable) throws RepositoryException {
            org.apache.jackrabbit.api.security.user.Group g = getGroup();
            return g != null && g.isMember(authorizable);
        }

        @Nonnull
        @Override
        Iterator<Authorizable> getMembers() throws RepositoryException {
            org.apache.jackrabbit.api.security.user.Group g = getGroup();
            return (g == null) ? Iterators.<Authorizable>emptyIterator() : g.getMembers();
        }

        @CheckForNull
        abstract org.apache.jackrabbit.api.security.user.Group getGroup()throws RepositoryException;
    }

    /**
     * Implementation of {@link AbstractGroupPrincipal} that reads the underlying
     * authorizable group lazily in case the group membership must be retrieved.
     */
    private final class GroupPrincipal extends BaseGroupPrincipal {

        private org.apache.jackrabbit.api.security.user.Group group;

        GroupPrincipal(@Nonnull String principalName, @Nonnull Tree groupTree) {
            super(principalName, groupTree);
        }

        @Override
        @CheckForNull
        org.apache.jackrabbit.api.security.user.Group getGroup() throws RepositoryException {
            if (group == null) {
                Authorizable authorizable = getUserManager().getAuthorizable(this);
                if (authorizable != null && authorizable.isGroup()) {
                    group = (org.apache.jackrabbit.api.security.user.Group) authorizable;
                }
            }
            return group;
        }
    }

    private final class CachedGroupPrincipal extends BaseGroupPrincipal {

        private org.apache.jackrabbit.api.security.user.Group group;

        CachedGroupPrincipal(@Nonnull String principalName) {
            super(principalName, "");
        }

        @Override
        String getOakPath() {
            String groupPath = getPath();
            return (groupPath == null) ? null : namePathMapper.getOakPath(getPath());
        }

        @Override
        public String getPath() {
            try {
                org.apache.jackrabbit.api.security.user.Group gr = getGroup();
                return (gr == null) ? null : gr.getPath();
            } catch (RepositoryException e) {
                log.error("Failed to retrieve path from group principal", e.getMessage());
                return null;
            }
        }

        @Override
        @CheckForNull
        org.apache.jackrabbit.api.security.user.Group getGroup() throws RepositoryException {
            if (group == null) {
                Authorizable authorizable = getUserManager().getAuthorizable(new PrincipalImpl(getName()));
                if (authorizable != null && authorizable.isGroup()) {
                    group = (org.apache.jackrabbit.api.security.user.Group) authorizable;
                }
            }
            return group;
        }
    }
}


