/*
 *  Copyright 2016 Red Hat, Inc. and/or its affiliates
 *  and other contributors as indicated by the @author tags.
 *
 *  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 org.keycloak.adapters.authorization;

import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.jboss.logging.Logger;
import org.keycloak.AuthorizationContext;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.adapters.OIDCHttpFacade;
import org.keycloak.adapters.spi.HttpFacade.Request;
import org.keycloak.adapters.spi.HttpFacade.Response;
import org.keycloak.authorization.client.AuthzClient;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig;
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.EnforcementMode;
import org.keycloak.representations.adapters.config.PolicyEnforcerConfig.PathConfig;
import org.keycloak.representations.idm.authorization.Permission;

/**
 * @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
 */
public abstract class AbstractPolicyEnforcer {

    private static Logger LOGGER = Logger.getLogger(AbstractPolicyEnforcer.class);
    private final PolicyEnforcerConfig enforcerConfig;
    private final PolicyEnforcer policyEnforcer;

    private Map<String, PathConfig> paths;
    private AuthzClient authzClient;
    private PathMatcher pathMatcher;

    public AbstractPolicyEnforcer(PolicyEnforcer policyEnforcer) {
        this.policyEnforcer = policyEnforcer;
        this.enforcerConfig = policyEnforcer.getEnforcerConfig();
        this.authzClient = policyEnforcer.getClient();
        this.pathMatcher = policyEnforcer.getPathMatcher();
        this.paths = policyEnforcer.getPaths();
    }

    public AuthorizationContext authorize(OIDCHttpFacade httpFacade) {
        EnforcementMode enforcementMode = this.enforcerConfig.getEnforcementMode();

        if (EnforcementMode.DISABLED.equals(enforcementMode)) {
            return createEmptyAuthorizationContext(true);
        }

        KeycloakSecurityContext securityContext = httpFacade.getSecurityContext();

        if (securityContext != null) {
            AccessToken accessToken = securityContext.getToken();

            if (accessToken != null) {
                Request request = httpFacade.getRequest();
                Response response = httpFacade.getResponse();
                String path = getPath(request);
                PathConfig pathConfig = this.pathMatcher.matches(path, this.paths);

                LOGGER.debugf("Checking permissions for path [%s] with config [%s].", request.getURI(), pathConfig);

                if (pathConfig == null) {
                    if (EnforcementMode.PERMISSIVE.equals(enforcementMode)) {
                        return createAuthorizationContext(accessToken);
                    }

                    LOGGER.debugf("Could not find a configuration for path [%s]", path);
                    response.sendError(403, "Could not find a configuration for path [" + path + "].");

                    return createEmptyAuthorizationContext(false);
                }

                if (EnforcementMode.DISABLED.equals(pathConfig.getEnforcementMode())) {
                    return createEmptyAuthorizationContext(true);
                }

                Set<String> requiredScopes = getRequiredScopes(pathConfig, request);

                if (isAuthorized(pathConfig, requiredScopes, accessToken, httpFacade)) {
                    try {
                        return createAuthorizationContext(accessToken);
                    } catch (Exception e) {
                        throw new RuntimeException("Error processing path [" + pathConfig.getPath() + "].", e);
                    }
                }

                if (!challenge(pathConfig, requiredScopes, httpFacade)) {
                    LOGGER.debugf("Sending challenge to the client. Path [%s]", pathConfig);
                    response.sendError(403, "Authorization failed.");
                }
            }
        }

        return createEmptyAuthorizationContext(false);
    }

    protected abstract boolean challenge(PathConfig pathConfig, Set<String> requiredScopes, OIDCHttpFacade facade);

    protected boolean isAuthorized(PathConfig actualPathConfig, Set<String> requiredScopes, AccessToken accessToken, OIDCHttpFacade httpFacade) {
        Request request = httpFacade.getRequest();
        PolicyEnforcerConfig enforcerConfig = getEnforcerConfig();

        if (isDefaultAccessDeniedUri(request, enforcerConfig)) {
            return true;
        }

        AccessToken.Authorization authorization = accessToken.getAuthorization();

        if (authorization == null) {
            return false;
        }

        List<Permission> permissions = authorization.getPermissions();
        boolean hasPermission = false;

        for (Permission permission : permissions) {
            if (permission.getResourceSetId() != null) {
                if (isResourcePermission(actualPathConfig, permission)) {
                    hasPermission = true;

                    if (actualPathConfig.isInstance() && !matchResourcePermission(actualPathConfig, permission)) {
                        continue;
                    }

                    if (hasResourceScopePermission(requiredScopes, permission, actualPathConfig)) {
                        LOGGER.debugf("Authorization GRANTED for path [%s]. Permissions [%s].", actualPathConfig, permissions);
                        if (request.getMethod().equalsIgnoreCase("DELETE") && actualPathConfig.isInstance()) {
                            this.paths.remove(actualPathConfig);
                        }
                        return true;
                    }
                }
            } else {
                if (hasResourceScopePermission(requiredScopes, permission, actualPathConfig)) {
                    hasPermission = true;
                    return true;
                }
            }
        }

        if (!hasPermission && EnforcementMode.PERMISSIVE.equals(actualPathConfig.getEnforcementMode())) {
            return true;
        }

        LOGGER.debugf("Authorization FAILED for path [%s]. No enough permissions [%s].", actualPathConfig, permissions);

        return false;
    }

    private boolean isDefaultAccessDeniedUri(Request request, PolicyEnforcerConfig enforcerConfig) {
        String accessDeniedPath = enforcerConfig.getOnDenyRedirectTo();

        if (accessDeniedPath != null) {
            if (request.getURI().contains(accessDeniedPath)) {
                return true;
            }
        }
        return false;
    }

    private boolean hasResourceScopePermission(Set<String> requiredScopes, Permission permission, PathConfig actualPathConfig) {
        Set<String> allowedScopes = permission.getScopes();
        return (allowedScopes.containsAll(requiredScopes) || allowedScopes.isEmpty());
    }

    protected AuthzClient getAuthzClient() {
        return this.authzClient;
    }

    protected PolicyEnforcerConfig getEnforcerConfig() {
        return enforcerConfig;
    }

    protected PolicyEnforcer getPolicyEnforcer() {
        return policyEnforcer;
    }

    private AuthorizationContext createEmptyAuthorizationContext(final boolean granted) {
        return new AuthorizationContext() {
            @Override
            public boolean hasPermission(String resourceName, String scopeName) {
                return granted;
            }

            @Override
            public boolean hasResourcePermission(String resourceName) {
                return granted;
            }

            @Override
            public boolean hasScopePermission(String scopeName) {
                return granted;
            }

            @Override
            public List<Permission> getPermissions() {
                return Collections.EMPTY_LIST;
            }

            @Override
            public boolean isGranted() {
                return granted;
            }
        };
    }

    private String getPath(Request request) {
        return request.getRelativePath();
    }

    private Set<String> getRequiredScopes(PathConfig pathConfig, Request request) {
        Set<String> requiredScopes = new HashSet<>();

        requiredScopes.addAll(pathConfig.getScopes());

        String method = request.getMethod();

        for (PolicyEnforcerConfig.MethodConfig methodConfig : pathConfig.getMethods()) {
            if (methodConfig.getMethod().equals(method)) {
                requiredScopes.addAll(methodConfig.getScopes());
            }
        }

        return requiredScopes;
    }

    private AuthorizationContext createAuthorizationContext(AccessToken accessToken) {
        return new AuthorizationContext(accessToken, this.paths);
    }

    private boolean isResourcePermission(PathConfig actualPathConfig, Permission permission) {
        // first we try a match using resource id
        boolean resourceMatch = matchResourcePermission(actualPathConfig, permission);

        // as a fallback, check if the current path is an instance and if so, check if parent's id matches the permission
        if (!resourceMatch && actualPathConfig.isInstance()) {
            resourceMatch = matchResourcePermission(actualPathConfig.getParentConfig(), permission);
        }

        return resourceMatch;
    }

    private boolean matchResourcePermission(PathConfig actualPathConfig, Permission permission) {
        return permission.getResourceSetId().equals(actualPathConfig.getId());
    }
}
