/**
 * Copyright (C) 2015 The Gravitee team (http://gravitee.io)
 *
 * 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.
 */
package io.gravitee.rest.api.management.rest.resource.auth;

import com.fasterxml.jackson.databind.JsonNode;
import com.jayway.jsonpath.JsonPath;
import com.jayway.jsonpath.ReadContext;
import io.gravitee.common.http.MediaType;
import io.gravitee.el.TemplateEngine;
import io.gravitee.el.spel.function.JsonPathFunction;
import io.gravitee.rest.api.idp.api.authentication.UserDetails;
import io.gravitee.rest.api.management.rest.utils.BlindTrustManager;
import io.gravitee.rest.api.model.*;
import io.gravitee.rest.api.model.configuration.identity.GroupMappingEntity;
import io.gravitee.rest.api.model.configuration.identity.RoleMappingEntity;
import io.gravitee.rest.api.model.configuration.identity.SocialIdentityProviderEntity;
import io.gravitee.rest.api.model.permissions.RoleScope;
import io.gravitee.rest.api.service.GroupService;
import io.gravitee.rest.api.service.MembershipService;
import io.gravitee.rest.api.service.RoleService;
import io.gravitee.rest.api.service.SocialIdentityProviderService;
import io.gravitee.rest.api.service.common.GraviteeContext;
import io.gravitee.rest.api.service.exceptions.GroupNotFoundException;
import io.gravitee.rest.api.service.exceptions.RoleNotFoundException;
import io.gravitee.rest.api.service.exceptions.UserNotFoundException;
import io.swagger.annotations.Api;
import org.glassfish.jersey.internal.util.collection.MultivaluedStringMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;

import javax.annotation.PostConstruct;
import javax.inject.Singleton;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.ws.rs.*;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.stream.Collectors;

import static org.springframework.security.core.authority.AuthorityUtils.commaSeparatedStringToAuthorityList;

/**
 * @author David BRASSELY (david.brassely at graviteesource.com)
 * @author Nicolas GERAUD (nicolas.geraud at graviteesource.com)
 * @author GraviteeSource Team
 */
@Singleton
@Api(tags = {"Portal", "OAuth2 Authentication"})
public class OAuth2AuthenticationResource extends AbstractAuthenticationResource {

    private static final Logger LOGGER = LoggerFactory.getLogger(OAuth2AuthenticationResource.class);

    private final static String TEMPLATE_ENGINE_PROFILE_ATTRIBUTE = "profile";

    @Autowired
    private SocialIdentityProviderService socialIdentityProviderService;

    @Autowired
    private GroupService groupService;

    @Autowired
    private RoleService roleService;

    @Autowired
    private Environment environment;

    private Client client;

    // Dirty hack: only used to force class loading
    static {
        try {
            LOGGER.trace("Loading class to initialize properly JsonPath Cache provider: " +
                    Class.forName(JsonPathFunction.class.getName()));
        } catch (ClassNotFoundException ignored) {
        }
    }

    private static final String ACCESS_TOKEN_PROPERTY = "access_token";

    @PostConstruct
    public void initClient() throws NoSuchAlgorithmException, KeyManagementException {
        final boolean trustAllEnabled = environment.getProperty("security.trustAll", Boolean.class, false);
        final ClientBuilder builder = ClientBuilder.newBuilder();
        if (trustAllEnabled) {
            SSLContext sc = SSLContext.getInstance("TLSv1.2");
            sc.init(null, new TrustManager[]{new BlindTrustManager()}, null);
            builder.sslContext(sc);
        }

        this.client = builder.build();
    }

    @POST
    @Path("exchange")
    @Produces(MediaType.APPLICATION_JSON)
    public Response tokenExchange(
            @PathParam(value = "identity") final String identity,
            @QueryParam(value = "token") final String token,
            @Context final HttpServletResponse servletResponse) throws IOException {
        SocialIdentityProviderEntity identityProvider = socialIdentityProviderService.findById(identity);

        if (identityProvider != null) {
            if (identityProvider.getTokenIntrospectionEndpoint() != null) {
                // Step1. Check the token by invoking the introspection endpoint
                final MultivaluedStringMap introspectData = new MultivaluedStringMap();
                introspectData.add(TOKEN, token);
                Response response = client
                        //TODO: what is the correct introspection URL here ?
                        .target(identityProvider.getTokenIntrospectionEndpoint())
                        .request(javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE)
                        .header(HttpHeaders.AUTHORIZATION,
                                String.format("Basic %s",
                                        Base64.getEncoder().encodeToString(
                                                (identityProvider.getClientId() + ':' + identityProvider.getClientSecret()).getBytes())))
                        .post(Entity.form(introspectData));
                introspectData.clear();

                if (response.getStatus() == Response.Status.OK.getStatusCode()) {
                    JsonNode introspectPayload = response.readEntity(JsonNode.class);
                    boolean active = introspectPayload.path("active").asBoolean(true);

                    if (active) {
                        return authenticateUser(identityProvider, servletResponse, token, null);
                    } else {
                        return Response
                                .status(Response.Status.UNAUTHORIZED)
                                .entity(introspectPayload)
                                .build();
                    }
                } else {
                    LOGGER.error("Token exchange failed with status {}: {}\n{}", response.getStatus(), response.getStatusInfo(), getResponseEntityAsString(response));
                }

                return Response
                        .status(response.getStatusInfo())
                        .entity(response.getEntity())
                        .build();
            } else {
                return Response.status(Response.Status.BAD_REQUEST)
                        .entity("Token exchange is not supported for this identity provider")
                        .build();
            }
        }

        return Response.status(Response.Status.NOT_FOUND).build();
    }

    @POST
    @Produces(MediaType.APPLICATION_JSON)
    public Response exchangeAuthorizationCode(
            @PathParam(value = "identity") String identity,
            @Valid @NotNull final Payload payload,
            @Context final HttpServletResponse servletResponse) throws IOException {
        SocialIdentityProviderEntity identityProvider = socialIdentityProviderService.findById(identity);

        if (identityProvider != null) {
            // Step 1. Exchange authorization code for access token.
            final MultivaluedStringMap accessData = new MultivaluedStringMap();
            accessData.add(CLIENT_ID_KEY, payload.getClientId());
            accessData.add(REDIRECT_URI_KEY, payload.getRedirectUri());
            accessData.add(CLIENT_SECRET, identityProvider.getClientSecret());
            accessData.add(CODE_KEY, payload.getCode());
            accessData.add(GRANT_TYPE_KEY, AUTH_CODE);

            Response response = client.target(identityProvider.getTokenEndpoint())
                    .request(javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE)
                    .post(Entity.form(accessData));
            accessData.clear();

            if (response.getStatus() == Response.Status.OK.getStatusCode()) {
                final String accessToken = (String) getResponseEntity(response).get(ACCESS_TOKEN_PROPERTY);
                return authenticateUser(identityProvider, servletResponse, accessToken, payload.getState());
            } else {
                LOGGER.error("Exchange authorization code failed with status {}: {}\n{}", response.getStatus(), response.getStatusInfo(), getResponseEntityAsString(response));
            }
            return Response
                    .status(Response.Status.UNAUTHORIZED)
                    .build();
        }

        return Response.status(Response.Status.NOT_FOUND).build();
    }

    /**
     * Retrieve profile information about the authenticated oauth end-user and authenticate it in Gravitee.
     *
     * @return
     */
    private Response authenticateUser(final SocialIdentityProviderEntity socialProvider,
                                      final HttpServletResponse servletResponse,
                                      final String accessToken,
                                      final String state) throws IOException {
        // Step 2. Retrieve profile information about the authenticated end-user.
        Response response = client
                .target(socialProvider.getUserInfoEndpoint())
                .request(javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE)
                .header(HttpHeaders.AUTHORIZATION, String.format(socialProvider.getAuthorizationHeader(), accessToken))
                .get();


        // Step 3. Process the authenticated user.
        final String userInfo = getResponseEntityAsString(response);
        if (response.getStatus() == Response.Status.OK.getStatusCode()) {
            return processUser(socialProvider, servletResponse, userInfo, state);
        } else {
            LOGGER.error("User info failed with status {}: {}\n{}", response.getStatus(), response.getStatusInfo(), userInfo);

        }

        return Response.status(response.getStatusInfo()).build();
    }

    private Response processUser(final SocialIdentityProviderEntity socialProvider, final HttpServletResponse servletResponse, final String userInfo, final String state) {
        Map<String, String> attrs = extractUserProfileAttributes(socialProvider.getUserProfileMapping(), userInfo);

        String email = attrs.get(SocialIdentityProviderEntity.UserProfile.EMAIL);
        if (email == null && socialProvider.isEmailRequired()) {
            throw new BadRequestException("No public email linked to your account");
        }

        boolean created = false;
        UserEntity user;

        // Compute group and role mappings
        // This is done BEFORE updating or creating the user account to ensure this one is properly created with correct
        // information (ie. mappings)
        Set<GroupEntity> userGroups = computeUserGroupsFromProfile(email, socialProvider.getGroupMappings(), userInfo);
        Set<RoleEntity> userRoles = computeUserRolesFromProfile(email, socialProvider.getRoleMappings(), userInfo);

        try {
            user = userService.findBySource(socialProvider.getId(), attrs.get(SocialIdentityProviderEntity.UserProfile.ID), false);

            // Update user information from its user info profile
            UpdateUserEntity updatedUser = new UpdateUserEntity();

            // User email is invariant
            updatedUser.setEmail(email);

            if (attrs.get(SocialIdentityProviderEntity.UserProfile.LASTNAME) != null) {
                updatedUser.setLastname(attrs.get(SocialIdentityProviderEntity.UserProfile.LASTNAME));
            }
            if (attrs.get(SocialIdentityProviderEntity.UserProfile.FIRSTNAME) != null) {
                updatedUser.setFirstname(attrs.get(SocialIdentityProviderEntity.UserProfile.FIRSTNAME));
            }
            if (attrs.get(SocialIdentityProviderEntity.UserProfile.PICTURE) != null) {
                updatedUser.setPicture(attrs.get(SocialIdentityProviderEntity.UserProfile.PICTURE));
            }

            user = userService.update(user.getId(), updatedUser);
        } catch (UserNotFoundException unfe) {
            final NewExternalUserEntity newUser = new NewExternalUserEntity();
            newUser.setEmail(email);
            newUser.setSource(socialProvider.getId());

            if (attrs.get(SocialIdentityProviderEntity.UserProfile.ID) != null) {
                newUser.setSourceId(attrs.get(SocialIdentityProviderEntity.UserProfile.ID));
            }
            if (attrs.get(SocialIdentityProviderEntity.UserProfile.LASTNAME) != null) {
                newUser.setLastname(attrs.get(SocialIdentityProviderEntity.UserProfile.LASTNAME));
            }
            if (attrs.get(SocialIdentityProviderEntity.UserProfile.FIRSTNAME) != null) {
                newUser.setFirstname(attrs.get(SocialIdentityProviderEntity.UserProfile.FIRSTNAME));
            }
            if (attrs.get(SocialIdentityProviderEntity.UserProfile.PICTURE) != null) {
                newUser.setPicture(attrs.get(SocialIdentityProviderEntity.UserProfile.PICTURE));
            }

            user = userService.create(newUser, true);
            created = true;
        }

        // Memberships must be refresh only when it is a user creation context or mappings should be synced during
        // later authentication
        List<MembershipService.Membership> groupMemberships = refreshUserGroups(user.getId(), socialProvider.getId(), userGroups);
        List<MembershipService.Membership> roleMemberships = refreshUserRoles(user.getId(), socialProvider.getId(), userRoles);

        if (created || socialProvider.isSyncMappings()) {
            refreshUserMemberships(user.getId(), socialProvider.getId(), groupMemberships, MembershipReferenceType.GROUP);
            refreshUserMemberships(user.getId(), socialProvider.getId(), roleMemberships,
                    MembershipReferenceType.ENVIRONMENT);
        }

        final Set<RoleEntity> roles = membershipService.getRoles(MembershipReferenceType.ENVIRONMENT, GraviteeContext.getCurrentEnvironment(), MembershipMemberType.USER, user.getId());
        roles.addAll(membershipService.getRoles(MembershipReferenceType.ORGANIZATION, GraviteeContext.getCurrentOrganization(), MembershipMemberType.USER, user.getId()));;

        final Set<GrantedAuthority> authorities = new HashSet<>();
        if (!roles.isEmpty()) {
            authorities.addAll(commaSeparatedStringToAuthorityList(roles.stream()
                    .map(r -> r.getScope().name() + ':' + r.getName()).collect(Collectors.joining(","))));
        }

        //set user to Authentication Context
        UserDetails userDetails = new UserDetails(user.getId(), "", authorities);
        userDetails.setEmail(email);
        SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()));

        return connectUser(user.getId(), state, servletResponse);
    }

    private Map<String, String> extractUserProfileAttributes(Map<String, String> userProfileMapping, String userInfo) {
        TemplateEngine templateEngine = TemplateEngine.templateEngine();
        templateEngine.getTemplateContext().setVariable(TEMPLATE_ENGINE_PROFILE_ATTRIBUTE, userInfo);

        ReadContext userInfoPath = JsonPath.parse(userInfo);
        HashMap<String, String> map = new HashMap<>(userProfileMapping.size());

        for (Map.Entry<String, String> entry : userProfileMapping.entrySet()) {
            String field = entry.getKey();
            String mapping = entry.getValue();

            if (mapping != null && !mapping.isEmpty()) {
                try {
                    if (mapping.contains("{#")) {
                        map.put(field, templateEngine.convert(mapping));
                    } else {
                        map.put(field, userInfoPath.read(mapping).toString());
                    }
                } catch (Exception e) {
                    LOGGER.warn("Using mapping: \"{}\", no fields are located in {}", mapping, userInfo);
                }
            }
        }

        return map;
    }

    private List<MembershipService.Membership> refreshUserGroups(String userId, String identityProviderId, Collection<GroupEntity> userGroups) {
        List<MembershipService.Membership> memberships = new ArrayList<>();

        // Get the default group roles from system
        List<RoleEntity> roleEntities = roleService.findDefaultRoleByScopes(RoleScope.API, RoleScope.APPLICATION);

        // Add groups to user
        for (GroupEntity groupEntity : userGroups) {
            for (RoleEntity roleEntity : roleEntities) {
                String defaultRole = roleEntity.getName();

                // If defined, get the override default role at the group level
                if (groupEntity.getRoles() != null) {
                    String groupDefaultRole = groupEntity.getRoles().get(RoleScope.valueOf(roleEntity.getScope().name()));
                    if (groupDefaultRole != null) {
                        defaultRole = groupDefaultRole;
                    }
                }

                MembershipService.Membership membership = new MembershipService.Membership(
                        new MembershipService.MembershipReference(MembershipReferenceType.GROUP, groupEntity.getId()),
                        new MembershipService.MembershipMember(userId, null, MembershipMemberType.USER),
                        new MembershipService.MembershipRole(mapScope(roleEntity.getScope()), defaultRole));

                membership.setSource(identityProviderId);

                memberships.add(membership);
            }
        }

        return memberships;
    }

    private List<MembershipService.Membership> refreshUserRoles(String userId, String identityProviderId, Collection<RoleEntity> userRoles) {
        return userRoles.stream()
                .map(roleEntity -> {
                    MembershipService.Membership membership = new MembershipService.Membership(
                            new MembershipService.MembershipReference(
                                    RoleScope.ENVIRONMENT == roleEntity.getScope() ?
                                            MembershipReferenceType.ENVIRONMENT
                                            : MembershipReferenceType.ORGANIZATION,
                                    RoleScope.ENVIRONMENT == roleEntity.getScope() ?
                                            GraviteeContext.getCurrentEnvironment()
                                            : GraviteeContext.getCurrentOrganization()),
                            new MembershipService.MembershipMember(userId, null, MembershipMemberType.USER),
                            new MembershipService.MembershipRole(
                                    RoleScope.valueOf(roleEntity.getScope().name()),
                                    roleEntity.getName()));

                    membership.setSource(identityProviderId);

                    return membership;
                }).collect(Collectors.toList());
    }

    /**
     * Refresh user memberships.
     *
     * @param userId User identifier.
     * @param identityProviderId The identity provider used to authenticate the user.
     * @param memberships List of memberships to associate to the user
     * @param types The types of user memberships to manage
     */
    private void refreshUserMemberships(String userId, String identityProviderId, List<MembershipService.Membership> memberships,
                                        MembershipReferenceType ... types) {
        // Get existing memberships for a given type
        List<UserMembership> userMemberships = new ArrayList<>();

        for (MembershipReferenceType type : types) {
            userMemberships.addAll(membershipService.findUserMembership(type, userId));
        }

        // Delete existing memberships
        userMemberships.forEach(membership -> {
            // Consider only membership "created by" the identity provider
            if (identityProviderId.equals(membership.getSource())) {
                membershipService.deleteReferenceMember(
                        MembershipReferenceType.valueOf(membership.getType()),
                        membership.getReference(),
                        MembershipMemberType.USER,
                        userId);
            }
        });

        // Create updated memberships
        memberships.forEach(membership -> membershipService.updateRoleToMemberOnReference(
                membership.getReference(),
                membership.getMember(),
                membership.getRole(),
                membership.getSource(),
                false));
    }

    /**
     * Calculate the list of groups to associate to a user according to its OIDC profile (ie. UserInfo)
     *
     * @param userId
     * @param mappings
     * @param userInfo
     * @return
     */
    private Set<GroupEntity> computeUserGroupsFromProfile(String userId, List<GroupMappingEntity> mappings, String userInfo) {
        if (mappings == null || mappings.isEmpty()) {
            return Collections.emptySet();
        }

        Set<GroupEntity> groups = new HashSet<>();

        for (GroupMappingEntity mapping : mappings) {
            TemplateEngine templateEngine = TemplateEngine.templateEngine();
            templateEngine.getTemplateContext().setVariable(TEMPLATE_ENGINE_PROFILE_ATTRIBUTE, userInfo);

            boolean match = templateEngine.getValue(mapping.getCondition(), boolean.class);

            trace(userId, match, mapping.getCondition());

            // Get groups
            if (match) {
                for (String groupName : mapping.getGroups()) {
                    try {
                        groups.add(groupService.findById(groupName));
                    } catch (GroupNotFoundException gnfe) {
                        LOGGER.error("Unable to create user, missing group in repository : {}", groupName);
                    }
                }
            }
        }

        return groups;
    }

    /**
     * Calculate the list of roles to associate to a user according to its OIDC profile (ie. UserInfo)
     *
     * @param userId
     * @param mappings
     * @param userInfo
     * @return
     */
    private Set<RoleEntity> computeUserRolesFromProfile(String userId, List<RoleMappingEntity> mappings, String userInfo) {
        if (mappings == null || mappings.isEmpty()) {
            return Collections.emptySet();
        }

        Set<RoleEntity> roles = new HashSet<>();

        for (RoleMappingEntity mapping : mappings) {
            TemplateEngine templateEngine = TemplateEngine.templateEngine();
            templateEngine.getTemplateContext().setVariable(TEMPLATE_ENGINE_PROFILE_ATTRIBUTE, userInfo);

            boolean match = templateEngine.getValue(mapping.getCondition(), boolean.class);

            trace(userId, match, mapping.getCondition());

            // Get roles
            if (match) {
                if (mapping.getEnvironments() != null) {
                    try {
                        mapping.getEnvironments().forEach(env ->
                                roleService
                                        .findByScopeAndName(RoleScope.ENVIRONMENT, env)
                                        .ifPresent(roles::add)
                        );
                    } catch (RoleNotFoundException rnfe) {
                        LOGGER.error("Unable to create user, missing role in repository : {}", mapping.getEnvironments());
                    }
                }

                if (mapping.getOrganizations() != null) {
                    try {
                        mapping.getOrganizations().forEach(org ->
                                roleService
                                        .findByScopeAndName(RoleScope.ORGANIZATION, org)
                                        .ifPresent(roles::add)
                        );
                    } catch (RoleNotFoundException rnfe) {
                        LOGGER.error("Unable to create user, missing role in repository : {}", mapping.getOrganizations());
                    }
                }
            }
        }

        return roles;
    }

    private void trace(String userId, boolean match, String condition) {
        if (LOGGER.isDebugEnabled()) {
            if (match) {
                LOGGER.debug("the expression {} match on {} user's info ", condition, userId);
            } else {
                LOGGER.debug("the expression {} didn't match {} on user's info ", condition, userId);
            }
        }
    }

    private RoleScope mapScope(io.gravitee.rest.api.model.permissions.RoleScope scope) {
        if (io.gravitee.rest.api.model.permissions.RoleScope.API == scope) {
            return RoleScope.API;
        } else {
            return RoleScope.APPLICATION;
        }
    }

}