/**
 * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information regarding copyright ownership. Apereo
 * 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 the
 * following location:
 *
 * <p>http://www.apache.org/licenses/LICENSE-2.0
 *
 * <p>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.apereo.portal.utils.cache;

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import java.io.Serializable;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.aopalliance.intercept.MethodInvocation;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apereo.services.persondir.support.IUsernameAttributeProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springmodules.cache.key.CacheKeyGenerator;

public class PersonDirectoryCacheKeyGenerator implements CacheKeyGenerator {
    protected final Log logger = LogFactory.getLog(this.getClass());

    private final LoadingCache<Method, CachableMethod> resolvedMethodCache =
            CacheBuilder.newBuilder()
                    .build(
                            new CacheLoader<Method, CachableMethod>() {
                                @Override
                                public CachableMethod load(Method key) throws Exception {
                                    return resolveCacheableMethod(key);
                                }
                            });

    private IUsernameAttributeProvider usernameAttributeProvider;
    private boolean ignoreEmptyAttributes = false;

    @Autowired
    public void setUsernameAttributeProvider(IUsernameAttributeProvider usernameAttributeProvider) {
        this.usernameAttributeProvider = usernameAttributeProvider;
    }

    /**
     * If seed attributes with empty values (null, empty string or empty list values) should be
     * ignored when generating the cache key. Defaults to false.
     */
    public void setIgnoreEmptyAttributes(boolean ignoreEmptyAttributes) {
        this.ignoreEmptyAttributes = ignoreEmptyAttributes;
    }

    @Override
    public Serializable generateKey(MethodInvocation methodInvocation) {
        // Determine the targeted CachableMethod
        final CachableMethod cachableMethod =
                this.resolvedMethodCache.getUnchecked(methodInvocation.getMethod());

        // Use the resolved cachableMethod to determine the seed Map and then get the hash of the
        // key elements
        final Object[] methodArguments = methodInvocation.getArguments();

        final CacheKey.CacheKeyBuilder<String, Serializable> cacheKeyBuilder =
                CacheKey.builder(cachableMethod.getName());

        switch (cachableMethod) {
                // Both methods that take a Map argument can just have the first argument returned
            case PEOPLE_MAP:
            case PEOPLE_MULTIVALUED_MAP:
            case MULTIVALUED_USER_ATTRIBUTES__MAP:
            case USER_ATTRIBUTES__MAP:
                {
                    final Map<String, Object> queryMap = (Map<String, Object>) methodArguments[0];

                    // If possible tag the cache key with the username
                    final String usernameAttribute =
                            this.usernameAttributeProvider.getUsernameAttribute();
                    Object usernameValue = queryMap.get(usernameAttribute);
                    if (usernameValue instanceof String) {
                        cacheKeyBuilder.addTag(
                                UsernameTaggedCacheEntryPurger.createCacheEntryTag(
                                        (String) usernameValue));
                    } else if (usernameValue instanceof List) {
                        final List usernameValueList = (List) usernameValue;
                        if (usernameValueList.size() == 1) {
                            usernameValue = usernameValueList.get(0);
                            if (usernameValue instanceof String) {
                                cacheKeyBuilder.addTag(
                                        UsernameTaggedCacheEntryPurger.createCacheEntryTag(
                                                (String) usernameValue));
                            }
                        }
                    }

                    for (final Map.Entry<String, Object> e : queryMap.entrySet()) {
                        final String key = e.getKey();
                        final Object value = e.getValue();

                        // Skip null/empty attribute values
                        if (ignoreEmptyAttributes
                                && (value == null
                                        || (value instanceof Collection
                                                && ((Collection) value).isEmpty())
                                        || (value instanceof Map && ((Map) value).isEmpty())
                                        || (value.getClass().isArray()
                                                && Array.getLength(value) == 0))) {
                            continue;
                        }

                        if (value == null || value instanceof Serializable) {
                            cacheKeyBuilder.put(key, (Serializable) value);
                        } else {
                            cacheKeyBuilder.put(key, value.getClass());
                        }
                    }
                    break;
                }

                // The multivalued attributes with a string needs to be converted to Map<String,
                // List<Object>>
            case MULTIVALUED_USER_ATTRIBUTES__STR:
                {
                    final String uid = (String) methodArguments[0];
                    if (StringUtils.isEmpty(uid)) {
                        break;
                    }

                    cacheKeyBuilder.add(uid);
                    cacheKeyBuilder.addTag(UsernameTaggedCacheEntryPurger.createCacheEntryTag(uid));
                    break;
                }

                // The single valued attributes with a string needs to be converted to Map<String,
                // Object>
            case PERSON_STR:
            case USER_ATTRIBUTES__STR:
                {
                    final String uid = (String) methodArguments[0];
                    if (StringUtils.isEmpty(uid)) {
                        break;
                    }
                    cacheKeyBuilder.add(uid);
                    cacheKeyBuilder.addTag(UsernameTaggedCacheEntryPurger.createCacheEntryTag(uid));
                    break;
                }

            case POSSIBLE_USER_ATTRIBUTE_NAMES:
            case AVAILABLE_QUERY_ATTRIBUTES:
                {
                    break;
                }

            default:
                {
                    throw new IllegalArgumentException(
                            "Unsupported CachableMethod resolved: '" + cachableMethod + "'");
                }
        }

        if (cacheKeyBuilder.size() == 0) {
            if (this.logger.isDebugEnabled()) {
                this.logger.debug(
                        "No cache key generated for MethodInvocation='" + methodInvocation + "'");
            }

            return null;
        }

        final CacheKey cacheKey = cacheKeyBuilder.build();
        if (this.logger.isDebugEnabled()) {
            this.logger.debug(
                    "Generated cache key '"
                            + cacheKey
                            + "' for MethodInvocation='"
                            + methodInvocation
                            + "'");
        }
        return cacheKey;
    }

    /**
     * Iterates over the {@link CachableMethod} instances to determine which instance the passed
     * {@link MethodInvocation} applies to.
     */
    protected CachableMethod resolveCacheableMethod(Method targetMethod) {
        final Class<?> targetClass = targetMethod.getDeclaringClass();
        for (final CachableMethod cachableMethod : CachableMethod.values()) {
            Method cacheableMethod = null;
            try {
                cacheableMethod =
                        targetClass.getMethod(cachableMethod.getName(), cachableMethod.getArgs());
            } catch (SecurityException e) {
                this.logger.warn(
                        "Security exception while attempting to if the target class '"
                                + targetClass
                                + "' implements the cachable method '"
                                + cachableMethod
                                + "'",
                        e);
            } catch (NoSuchMethodException e) {
                final String message =
                        "Taret class '"
                                + targetClass
                                + "' does not implement possible cachable method '"
                                + cachableMethod
                                + "'. Is the advice applied to the correct bean and methods?";

                if (this.logger.isDebugEnabled()) {
                    this.logger.debug(message, e);
                } else {
                    this.logger.warn(message);
                }
            }

            if (targetMethod.equals(cacheableMethod)) {
                return cachableMethod;
            }
        }

        throw new IllegalArgumentException(
                "Do not know how to generate a cache for for '"
                        + targetMethod
                        + "' on class '"
                        + targetClass
                        + "'. Is the advice applied to the correct bean and methods?");
    }

    /** Methods on {@link org.jasig.services.persondir.IPersonAttributeDao} that are cachable */
    public enum CachableMethod {
        @Deprecated
        MULTIVALUED_USER_ATTRIBUTES__MAP("getMultivaluedUserAttributes", Map.class),
        @Deprecated
        MULTIVALUED_USER_ATTRIBUTES__STR("getMultivaluedUserAttributes", String.class),
        @Deprecated
        USER_ATTRIBUTES__MAP("getUserAttributes", Map.class),
        @Deprecated
        USER_ATTRIBUTES__STR("getUserAttributes", String.class),

        PERSON_STR("getPerson", String.class),
        PEOPLE_MAP("getPeople", Map.class),
        PEOPLE_MULTIVALUED_MAP("getPeopleWithMultivaluedAttributes", Map.class),
        POSSIBLE_USER_ATTRIBUTE_NAMES("getPossibleUserAttributeNames"),
        AVAILABLE_QUERY_ATTRIBUTES("getAvailableQueryAttributes");

        private final String name;
        private final Class<?>[] args;

        private CachableMethod(String name, Class<?>... args) {
            this.name = name;
            this.args = args;
        }

        public String getName() {
            return this.name;
        }

        public Class<?>[] getArgs() {
            return this.args;
        }

        @Override
        public String toString() {
            return this.name + "(" + Arrays.asList(this.args) + ")";
        }
    }
}
