/**
 * Copyright (c) 2011-2014, OpenIoT
 *
 * This library is free software; you can redistribute it and/or
 * modify it either under the terms of the GNU Lesser General Public
 * License version 2.1 as published by the Free Software Foundation
 * (the "LGPL"). If you do not alter this
 * notice, a recipient may use your version of this file under the LGPL.
 *
 * You should have received a copy of the LGPL along with this library
 * in the file COPYING-LGPL-2.1; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
 *
 * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY
 * OF ANY KIND, either express or implied. See the LGPL  for
 * the specific language governing rights and limitations.
 *
 * Contact: OpenIoT mailto: info@openiot.eu
 */

package org.openiot.security.client;

import static org.openiot.security.client.SecurityConstants.CALLER_ACCESS_TOKEN;
import static org.openiot.security.client.SecurityConstants.CALLER_CLIENT_ID;
import static org.openiot.security.client.SecurityConstants.ERROR;
import static org.openiot.security.client.SecurityConstants.ROLE_PERMISSIONS;
import static org.openiot.security.client.SecurityConstants.TARGET_CLIENT_ID;
import static org.openiot.security.client.SecurityConstants.USER_ACCESS_TOKEN;
import static org.openiot.security.client.SecurityConstants.*;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.shiro.authz.Permission;
import org.apache.shiro.authz.permission.PermissionResolver;
import org.apache.shiro.authz.permission.WildcardPermissionResolver;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
import org.apache.shiro.subject.PrincipalCollection;
import org.pac4j.core.exception.HttpCommunicationException;
import org.pac4j.oauth.client.BaseOAuth20Client;
import org.pac4j.oauth.profile.JsonHelper;
import org.pac4j.oauth.profile.casoauthwrapper.CasOAuthWrapperProfile;
import org.scribe.model.OAuthConstants;
import org.scribe.model.ProxyOAuthRequest;
import org.scribe.model.Response;
import org.scribe.model.Verb;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.databind.JsonNode;

/**
 * This class provides authorization information to the clients. The permissions and roles are
 * obtained for the tokens on services as follows:
 * <ul>
 * <li>If OAuthorizationCredentials does not have caller credentials, then the authorization
 * information is retrieved and consulted for its token on its service (specified by clientId)</li>
 * <li>If OAuthorizationCredentials has caller credentials, but the caller does not have a caller
 * (i.e., chain of 1 credentials), then the authorization information is retrieved and consulted for
 * the caller's token on the current service (specified by clientId) unless the target service is
 * explicitly specified</li>
 * <li>If OAuthorizationCredentials has a caller credentials, which also has a caller (i.e., chain
 * of 3 credentials), then the authorization information is retrieved and consulted for the the last
 * caller's token on the current service (specified by clientId) unless the target service is
 * explicitly specified</li>
 * </ul>
 * If the target service is different than the current service, the user must have the required
 * permission for retrieving authorization information on the target service.
 * 
 * @author Mehdi Riahi
 * 
 */
public class AuthorizationManager implements ClearCacheListener {

	// TODO: use the subject's session for caching authorization data and back the session store by
	// Ehcache.

	private static Logger logger = LoggerFactory.getLogger(AuthorizationManager.class);

	private Cache<CacheKey, Map<String, Set<Permission>>> cacheManager;

	private BaseOAuth20Client<?> client;

	private boolean cachingEnabled = false;

	private String permissionsURL;

	private PermissionResolver permissionResolver = new WildcardPermissionResolver();

	public AuthorizationManager() {

	}

	public void setCacheManager(CacheManager cacheManager) {
		if (cacheManager != null) {
			logger.debug("Setting the cache manager to {}", cacheManager.getClass().getCanonicalName());
			this.cacheManager = cacheManager.<CacheKey, Map<String, Set<Permission>>> getCache("AuthorizationManager-Cache");
			cachingEnabled = true;
		}
	}

	public void setPermissionsURL(String permissionsURL) {
		this.permissionsURL = permissionsURL;
	}

	public boolean isCachingEnabled() {
		return cachingEnabled;
	}

	public void setClient(BaseOAuth20Client<?> client) {
		this.client = client;
	}

	/**
	 * Sends a request to the server to check if the token is expired.
	 * 
	 * @param credentials
	 * @return the expired token or <code>null</code> if non of the tokens in
	 *         <code>credentials</code> are expired
	 */
	public String getExpiredAccessToken(OAuthorizationCredentials credentials) {
		if (cachingEnabled)
			cacheManager.remove(new CacheKey(credentials.getClientId(), credentials));
		try {
			getAuthorizationInfo(credentials, credentials.getClientId());
		} catch (AccessTokenExpiredException e) {
			return e.getToken();
		}
		return null;
	}

	void reset(String token) {
		clearCacheForToken(token);
	}

	public boolean hasPermission(String permStr, OAuthorizationCredentials credentials) {
		if (credentials == null)
			return false;
		return hasPermission(permStr, credentials.getClientId(), credentials);
	}

	public boolean hasPermission(String permStr, String targetClientId, OAuthorizationCredentials credentials) {
		if (credentials == null)
			return false;
		Permission perm = permissionResolver.resolvePermission(permStr);

		Map<String, Set<Permission>> authorizationInfo = getAuthorizationInfo(credentials, targetClientId == null ? credentials.getClientId() : targetClientId);
		boolean hasPerm = false;
		for (Set<Permission> permSet : authorizationInfo.values()) {
			if (hasPerm)
				break;
			for (Permission permission : permSet)
				if (permission.implies(perm)) {
					hasPerm = true;
					break;
				}
		}
		return hasPerm;
	}

	public boolean hasRole(String role, OAuthorizationCredentials credentials) {
		if (credentials == null)
			return false;
		return hasRole(role, credentials.getClientId(), credentials);
	}

	public boolean hasRole(String role, String targetClientId, OAuthorizationCredentials credentials) {
		if (credentials == null)
			return false;
		Map<String, Set<Permission>> authorizationInfo = getAuthorizationInfo(credentials, targetClientId == null ? credentials.getClientId() : targetClientId);
		return authorizationInfo.containsKey(role);
	}

	protected Map<String, Set<Permission>> getAuthorizationInfo(final OAuthorizationCredentials credentials, final String targetClientId) {
		Map<String, Set<Permission>> authorizationInfo = null;

		CacheKey key = new CacheKey(targetClientId, credentials);
		if (cachingEnabled)
			authorizationInfo = cacheManager.get(key);

		if (authorizationInfo == null) {
			authorizationInfo = getAuthorizationInfoInternal(credentials, targetClientId);

			if (cachingEnabled)
				cacheManager.put(key, authorizationInfo);
		}

		return authorizationInfo;
	}

	protected Map<String, Set<Permission>> getAuthorizationInfoInternal(final OAuthorizationCredentials credentials, final String targetClientId)
			throws AccessTokenExpiredException {
		Map<String, Set<Permission>> map = new HashMap<String, Set<Permission>>();

		final String body = sendRequestForPermissions(credentials, targetClientId);
		JsonNode json = JsonHelper.getFirstNode(body);
		if (json != null) {
			JsonNode errorNode = json.get(ERROR);
			if (errorNode != null) {
				String errorMsg = errorNode.asText();
				logger.info("Error returned: {}", errorMsg);
				if (errorMsg.startsWith(EXPIRED_ACCESS_TOKEN)) {
					String token = credentials.getAccessToken();
					if (errorMsg.endsWith("_for_caller"))
						token = credentials.getCallerCredentials().getAccessToken();
					else if (errorMsg.endsWith("_for_user"))
						if (credentials.getCallerCredentials().getCallerCredentials() == null)
							token = credentials.getCallerCredentials().getAccessToken();
						else
							token = credentials.getCallerCredentials().getCallerCredentials().getAccessToken();
					throw new AccessTokenExpiredException(token, errorMsg);
				}
			} else {
				json = json.get(ROLE_PERMISSIONS);
				if (json != null) {
					final Iterator<JsonNode> nodes = json.iterator();
					while (nodes.hasNext()) {
						for (Iterator<Entry<String, JsonNode>> fields = nodes.next().fields(); fields.hasNext();) {
							Entry<String, JsonNode> next = fields.next();

							logger.debug("next role: {}", next.getKey());

							final HashSet<Permission> permissionsSet = new HashSet<Permission>();
							map.put(next.getKey(), permissionsSet);
							Iterator<JsonNode> permIter = next.getValue().iterator();
							while (permIter.hasNext()) {
								json = permIter.next();
								String permission = json.asText();
								permissionsSet.add(permissionResolver.resolvePermission(permission));
								logger.debug("next permission: {}", permission);
							}
						}
					}
				}
			}
		}

		return map;
	}

	protected String sendRequestForPermissions(final OAuthorizationCredentials credentials, final String targetClientId) {
		logger.debug("accessToken : {} / permissionsUrl : {}", credentials.getAccessToken(), permissionsURL);
		final long t0 = System.currentTimeMillis();
		final ProxyOAuthRequest request = new ProxyOAuthRequest(Verb.GET, permissionsURL, client.getConnectTimeout(), client.getReadTimeout(),
				client.getProxyHost(), client.getProxyPort());

		request.addQuerystringParameter(OAuthConstants.CLIENT_ID, credentials.getClientId());
		request.addQuerystringParameter(OAuthConstants.ACCESS_TOKEN, credentials.getAccessToken());

		String userToken = credentials.getAccessToken();
		final OAuthorizationCredentials callerCredentials = credentials.getCallerCredentials();

		if (callerCredentials != null) {
			final OAuthorizationCredentials userCredentials = callerCredentials.getCallerCredentials();
			if (userCredentials != null) {
				userToken = userCredentials.getAccessToken();
				request.addQuerystringParameter(USER_CLIENT_ID, userCredentials.getClientId());

				request.addQuerystringParameter(CALLER_CLIENT_ID, callerCredentials.getClientId());
				request.addQuerystringParameter(CALLER_ACCESS_TOKEN, callerCredentials.getAccessToken());
			} else {
				userToken = callerCredentials.getAccessToken();
				request.addQuerystringParameter(USER_CLIENT_ID, callerCredentials.getClientId());
			}
		}

		request.addQuerystringParameter(USER_ACCESS_TOKEN, userToken);
		request.addQuerystringParameter(TARGET_CLIENT_ID, targetClientId);

		final Response response = request.send();
		final int code = response.getCode();
		final String body = response.getBody();
		final long t1 = System.currentTimeMillis();
		logger.debug("Request took : " + (t1 - t0) + " ms for : " + permissionsURL);
		logger.debug("response code : {} / response body : {}", code, body);
		if (code != 200) {
			logger.error("Failed to get permissions, code : " + code + " / body : " + body);
			throw new HttpCommunicationException(code, body);
		}
		return body;
	}

	@Override
	public void clearCache(PrincipalCollection principals) {
		if (cachingEnabled) {
			if (principals == null) {
				logger.debug("Clearing cache");
				cacheManager.clear();
			} else {
				final CasOAuthWrapperProfile profile = principals.oneByType(CasOAuthWrapperProfile.class);
				String accessToken = profile.getAccessToken();
				logger.debug("Clearing cache for accessToken: {} ", accessToken);
				clearCacheForToken(accessToken);
			}
		}
	}

	private void clearCacheForToken(String accessToken) {
		Set<CacheKey> keys = cacheManager.keys();
		for (CacheKey key : keys) {
			if (key.credentials.containsToken(accessToken))
				cacheManager.remove(key);
		}
	}

	private static class CacheKey {
		String targetClientId;
		OAuthorizationCredentials credentials;

		public CacheKey(String targetClientId, OAuthorizationCredentials credentials) {
			this.targetClientId = targetClientId;
			this.credentials = credentials;
		}

		@Override
		public int hashCode() {
			HashCodeBuilder builder = new HashCodeBuilder();
			builder.append(targetClientId);
			builder.append(credentials);
			return builder.build();
		}

		@Override
		public boolean equals(Object obj) {
			return EqualsBuilder.reflectionEquals(this, obj);
		}

		@Override
		public String toString() {
			return ToStringBuilder.reflectionToString(this);
		}

	}

}
