package com.thinkbiganalytics.metadata.modeshape.user;

/*-
 * #%L
 * thinkbig-metadata-modeshape
 * %%
 * Copyright (C) 2017 ThinkBig Analytics
 * %%
 * Licensed 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.
 * #L%
 */

import com.thinkbiganalytics.metadata.api.user.GroupAlreadyExistsException;
import com.thinkbiganalytics.metadata.api.user.User;
import com.thinkbiganalytics.metadata.api.user.UserAlreadyExistsException;
import com.thinkbiganalytics.metadata.api.user.UserGroup;
import com.thinkbiganalytics.metadata.api.user.UserGroup.ID;
import com.thinkbiganalytics.metadata.api.user.UserProvider;
import com.thinkbiganalytics.metadata.modeshape.BaseJcrProvider;
import com.thinkbiganalytics.metadata.modeshape.MetadataRepositoryException;
import com.thinkbiganalytics.metadata.modeshape.common.JcrEntity;
import com.thinkbiganalytics.metadata.modeshape.common.UsersPaths;
import com.thinkbiganalytics.metadata.modeshape.support.JcrQueryUtil;
import com.thinkbiganalytics.metadata.modeshape.support.JcrUtil;

import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;

import javax.annotation.Nonnull;
import javax.jcr.ItemNotFoundException;
import javax.jcr.Node;
import javax.jcr.RepositoryException;
import javax.jcr.Session;

/**
 * Provides access to {@link User} objects stored in a JCR repository.
 */
public class JcrUserProvider extends BaseJcrProvider<Object, Serializable> implements UserProvider {

    @Nonnull
    @Override
    public User ensureUser(@Nonnull final String systemName) {
        return createUser(systemName, true);
    }

    @Nonnull
    @Override
    public Optional<User> findUserBySystemName(@Nonnull final String systemName) {
        final String query = "SELECT * FROM [" + JcrUser.NODE_TYPE + "] AS user WHERE NAME() = $systemName";
        final Map<String, String> bindParams = Collections.singletonMap("systemName", encodeUserName(systemName));
        return Optional.ofNullable(JcrQueryUtil.findFirst(getSession(), query, bindParams, getEntityClass()));
    }

    @Override
    public Class<? extends User> getEntityClass() {
        return JcrUser.class;
    }

    @Override
    public Class<? extends JcrEntity> getJcrEntityClass() {
        return JcrUser.class;
    }

    @Override
    public String getNodeType(@Nonnull final Class<? extends JcrEntity> jcrEntityType) {
        return JcrUser.NODE_TYPE;
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.BaseProvider#resolveId(java.io.Serializable)
     */
    @Override
    public User.ID resolveId(@Nonnull final Serializable fid) {
        return new JcrUser.UserId(fid);
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.user.UserProvider#resolveUserId(java.io.Serializable)
     */
    @Override
    public User.ID resolveUserId(@Nonnull Serializable id) {
        return new JcrUser.UserId(id);
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.user.UserProvider#resolveGroupId(java.io.Serializable)
     */
    @Override
    public UserGroup.ID resolveGroupId(@Nonnull Serializable id) {
        return new JcrUserGroup.UserGroupId(id);
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.user.UserProvider#userExists(java.lang.String)
     */
    @Override
    public boolean userExists(@Nonnull String username) {
        // TODO: is there a more efficient query than this?
        return findUserBySystemName(username).isPresent();
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.user.UserProvider#findUserById(com.thinkbiganalytics.metadata.api.user.User.ID)
     */
    @Nonnull
    @Override
    public Optional<User> findUserById(@Nonnull User.ID id) {
        try {
            Node node = getSession().getNodeByIdentifier(id.toString());

            if (node.isNodeType(JcrUser.NODE_TYPE)) {
                return Optional.of(new JcrUser(node));
            } else {
                // TODO: should we thrown an exception if the ID is not for a user?
                return Optional.empty();
            }
        } catch (ItemNotFoundException e) {
            return Optional.empty();
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed attempting to find the user with ID: " + id, e);
        }
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.user.UserProvider#getUsers()
     */
    @Nonnull
    @Override
    public Iterable<User> findUsers() {
        String query = "SELECT * FROM [" + JcrUser.NODE_TYPE + "]";
        return findIterable(query, User.class, JcrUser.class);
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.user.UserProvider#createUser(java.lang.String)
     */
    @Nonnull
    @Override
    public User createUser(@Nonnull String username) {
        return createUser(username, false);
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.user.UserProvider#findGroupById(com.thinkbiganalytics.metadata.api.user.UserGroup.ID)
     */
    @Nonnull
    @Override
    public Optional<UserGroup> findGroupById(ID id) {
        try {
            Node node = getSession().getNodeByIdentifier(id.toString());

            if (node.isNodeType(JcrUserGroup.NODE_TYPE)) {
                return Optional.of(new JcrUserGroup(node));
            } else {
                // TODO: should we thrown an exception if the ID is not for a group?
                return Optional.empty();
            }
        } catch (ItemNotFoundException e) {
            return Optional.empty();
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed attempting to find the group with ID: " + id, e);
        }
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.user.UserProvider#findGroupByName(java.lang.String)
     */
    @Nonnull
    @Override
    public Optional<UserGroup> findGroupByName(@Nonnull final String groupName) {
        final String query = "SELECT * FROM [" + JcrUserGroup.NODE_TYPE + "] AS user WHERE NAME() = $groupName";
        final Map<String, String> bindParams = Collections.singletonMap("groupName", encodeGroupName(groupName));
        return Optional.ofNullable(JcrQueryUtil.findFirst(getSession(), query, bindParams, JcrUserGroup.class));
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.user.UserProvider#getGroups()
     */
    @Nonnull
    @Override
    public Iterable<UserGroup> findGroups() {
        String query = "SELECT * FROM [" + JcrUserGroup.NODE_TYPE + "]";
        return findIterable(query, UserGroup.class, JcrUserGroup.class);
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.user.UserProvider#ensureGroup(java.lang.String)
     */
    @Nonnull
    @Override
    public UserGroup ensureGroup(@Nonnull String groupName) {
        return createGroup(groupName, true);
    }

    /* (non-Javadoc)
     * @see com.thinkbiganalytics.metadata.api.user.UserProvider#createGroup(java.lang.String)
     */
    @Nonnull
    @Override
    public UserGroup createGroup(@Nonnull String groupName) {
        return createGroup(groupName, false);
    }

    /**
     * Creates a new group with the specified name.
     *
     * @param groupName the name of the group
     * @param ensure    {@code true} to return the group if it already exists, or {@code false} to throw an exception
     * @return the group
     * @throws GroupAlreadyExistsException if the group already exists and {@code ensure} is {@code false}
     * @throws MetadataRepositoryException if the group could not be created
     */
    @Nonnull
    private UserGroup createGroup(@Nonnull final String groupName, final boolean ensure) {
        final Session session = getSession();
        final String safeGroupName = encodeGroupName(groupName);
        final String groupPath = UsersPaths.groupPath(safeGroupName).toString();

        try {
            final Node groupsNode = session.getRootNode().getNode(UsersPaths.GROUPS.toString());

            if (session.getRootNode().hasNode(groupPath)) {
                if (ensure) {
                    return JcrUtil.getJcrObject(groupsNode, safeGroupName, JcrUserGroup.class);
                } else {
                    throw new GroupAlreadyExistsException(groupName);
                }
            } else {
                return JcrUtil.getOrCreateNode(groupsNode, safeGroupName, JcrUserGroup.NODE_TYPE, JcrUserGroup.class);
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed attempting to create a new group with name: " + groupName, e);
        }
    }

    /**
     * Creates a new user with the specified name.
     *
     * @param username the name of the user
     * @param ensure   {@code true} to return the user if it already exists, or {@code false} to throw an exception
     * @return the user
     * @throws UserAlreadyExistsException  if the user already exists and {@code ensure} is {@code false}
     * @throws MetadataRepositoryException if the user could not be created
     */
    @Nonnull
    private User createUser(@Nonnull final String username, final boolean ensure) {
        final Session session = getSession();
        final String safeUserName = encodeUserName(username);
        final String userPath = UsersPaths.userPath(username).toString();

        try {
            final Node usersNode = session.getRootNode().getNode(UsersPaths.USERS.toString());

            if (session.getRootNode().hasNode(userPath)) {
                if (ensure) {
                    return JcrUtil.getJcrObject(usersNode, safeUserName, JcrUser.class);
                } else {
                    throw new UserAlreadyExistsException(username);
                }
            } else {
                return JcrUtil.getOrCreateNode(usersNode, safeUserName, JcrUser.NODE_TYPE, JcrUser.class);
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed attempting to create a new user with name: " + username, e);
        }
    }

    @Override
    public void deleteGroup(@Nonnull final UserGroup group) {
        delete(group);
    }

    @Override
    public void deleteUser(@Nonnull final User user) {
        delete(user);
    }

    @Nonnull
    @Override
    public UserGroup updateGroup(@Nonnull final UserGroup group) {
        return (UserGroup) update(group);
    }

    @Nonnull
    @Override
    public User updateUser(@Nonnull final User user) {
        return (User) update(user);
    }

    /**
     * Encodes the specified group name for use with JCR methods.
     *
     * @param groupName the raw group name
     * @return the encoded group name
     */
    @Nonnull
    private String encodeGroupName(@Nonnull final String groupName) {
        try {
            return URLEncoder.encode(groupName, JcrUserGroup.ENCODING);
        } catch (UnsupportedEncodingException e) {
            throw new IllegalStateException("Unsupported encoding for system name of group: " + groupName, e);
        }
    }

    /**
     * Encodes the specified user name for use with JCR methods.
     *
     * @param username the raw user name
     * @return the encoded user name
     */
    @Nonnull
    private String encodeUserName(@Nonnull final String username) {
        try {
            return URLEncoder.encode(username, JcrUser.ENCODING);
        } catch (UnsupportedEncodingException e) {
            throw new IllegalStateException("Unsupported encoding for system name of user: " + username, e);
        }
    }
}
