/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF 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
 *
 *   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.apache.commons.jcs3.jcache.cdi;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.logging.Logger;

import javax.annotation.PreDestroy;
import javax.cache.annotation.CacheDefaults;
import javax.cache.annotation.CacheKey;
import javax.cache.annotation.CacheKeyGenerator;
import javax.cache.annotation.CachePut;
import javax.cache.annotation.CacheRemove;
import javax.cache.annotation.CacheRemoveAll;
import javax.cache.annotation.CacheResolverFactory;
import javax.cache.annotation.CacheResult;
import javax.cache.annotation.CacheValue;
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.context.spi.CreationalContext;
import javax.enterprise.inject.spi.Bean;
import javax.enterprise.inject.spi.BeanManager;
import javax.inject.Inject;
import javax.interceptor.InvocationContext;

@ApplicationScoped
public class CDIJCacheHelper
{
    private static final Logger LOGGER = Logger.getLogger(CDIJCacheHelper.class.getName());
    private static final boolean CLOSE_CACHE = !Boolean.getBoolean("org.apache.commons.jcs3.jcache.cdi.skip-close");

    private volatile CacheResolverFactoryImpl defaultCacheResolverFactory; // lazy to not create any cache if not needed
    private final CacheKeyGeneratorImpl defaultCacheKeyGenerator = new CacheKeyGeneratorImpl();

    private final Collection<CreationalContext<?>> toRelease = new ArrayList<>();
    private final ConcurrentMap<MethodKey, MethodMeta> methods = new ConcurrentHashMap<>();

    @Inject
    private BeanManager beanManager;

    @PreDestroy
    private void release() {
        if (CLOSE_CACHE && defaultCacheResolverFactory != null)
        {
            defaultCacheResolverFactory.release();
        }
        for (final CreationalContext<?> cc : toRelease)
        {
            try
            {
                cc.release();
            }
            catch (final RuntimeException re)
            {
                LOGGER.warning(re.getMessage());
            }
        }
    }

    public MethodMeta findMeta(final InvocationContext ic)
    {
        final Method mtd = ic.getMethod();
        final Class<?> refType = findKeyType(ic.getTarget());
        final MethodKey key = new MethodKey(refType, mtd);
        MethodMeta methodMeta = methods.get(key);
        if (methodMeta == null)
        {
            synchronized (this)
            {
                methodMeta = methods.get(key);
                if (methodMeta == null)
                {
                    methodMeta = createMeta(ic);
                    methods.put(key, methodMeta);
                }
            }
        }
        return methodMeta;
    }

    private Class<?> findKeyType(final Object target)
    {
        if (null == target)
        {
            return null;
        }
        return target.getClass();
    }

    // it is unlikely we have all annotations but for now we have a single meta model
    private MethodMeta createMeta(final InvocationContext ic)
    {
        final CacheDefaults defaults = findDefaults(ic.getTarget() == null ? null : ic.getTarget()
                                                      .getClass(), ic.getMethod());

        final Class<?>[] parameterTypes = ic.getMethod().getParameterTypes();
        final Annotation[][] parameterAnnotations = ic.getMethod().getParameterAnnotations();
        final List<Set<Annotation>> annotations = new ArrayList<>();
        for (final Annotation[] parameterAnnotation : parameterAnnotations)
        {
            final Set<Annotation> set = new HashSet<>(parameterAnnotation.length);
            set.addAll(Arrays.asList(parameterAnnotation));
            annotations.add(set);
        }

        final Set<Annotation> mtdAnnotations = new HashSet<>(Arrays.asList(ic.getMethod().getAnnotations()));
        final CacheResult cacheResult = ic.getMethod().getAnnotation(CacheResult.class);
        final String cacheResultCacheResultName = cacheResult == null ? null : defaultName(ic.getMethod(), defaults, cacheResult.cacheName());
        final CacheResolverFactory cacheResultCacheResolverFactory = cacheResult == null ?
                null : cacheResolverFactoryFor(defaults, cacheResult.cacheResolverFactory());
        final CacheKeyGenerator cacheResultCacheKeyGenerator = cacheResult == null ?
                null : cacheKeyGeneratorFor(defaults, cacheResult.cacheKeyGenerator());

        final CachePut cachePut = ic.getMethod().getAnnotation(CachePut.class);
        final String cachePutCachePutName = cachePut == null ? null : defaultName(ic.getMethod(), defaults, cachePut.cacheName());
        final CacheResolverFactory cachePutCacheResolverFactory = cachePut == null ?
                null : cacheResolverFactoryFor(defaults, cachePut.cacheResolverFactory());
        final CacheKeyGenerator cachePutCacheKeyGenerator = cachePut == null ?
                null : cacheKeyGeneratorFor(defaults, cachePut.cacheKeyGenerator());

        final CacheRemove cacheRemove = ic.getMethod().getAnnotation(CacheRemove.class);
        final String cacheRemoveCacheRemoveName = cacheRemove == null ? null : defaultName(ic.getMethod(), defaults, cacheRemove.cacheName());
        final CacheResolverFactory cacheRemoveCacheResolverFactory = cacheRemove == null ?
                null : cacheResolverFactoryFor(defaults, cacheRemove.cacheResolverFactory());
        final CacheKeyGenerator cacheRemoveCacheKeyGenerator = cacheRemove == null ?
                null : cacheKeyGeneratorFor(defaults, cacheRemove.cacheKeyGenerator());

        final CacheRemoveAll cacheRemoveAll = ic.getMethod().getAnnotation(CacheRemoveAll.class);
        final String cacheRemoveAllCacheRemoveAllName = cacheRemoveAll == null ? null : defaultName(ic.getMethod(), defaults, cacheRemoveAll.cacheName());
        final CacheResolverFactory cacheRemoveAllCacheResolverFactory = cacheRemoveAll == null ?
                null : cacheResolverFactoryFor(defaults, cacheRemoveAll.cacheResolverFactory());

        return new MethodMeta(
                parameterTypes,
                annotations,
                mtdAnnotations,
                keyParameterIndexes(ic.getMethod()),
                getValueParameter(annotations),
                getKeyParameters(annotations),
                cacheResultCacheResultName,
                cacheResultCacheResolverFactory,
                cacheResultCacheKeyGenerator,
                cacheResult,
                cachePutCachePutName,
                cachePutCacheResolverFactory,
                cachePutCacheKeyGenerator,
                cachePut != null && cachePut.afterInvocation(),
                cachePut,
                cacheRemoveCacheRemoveName,
                cacheRemoveCacheResolverFactory,
                cacheRemoveCacheKeyGenerator,
                cacheRemove != null && cacheRemove.afterInvocation(),
                cacheRemove,
                cacheRemoveAllCacheRemoveAllName,
                cacheRemoveAllCacheResolverFactory,
                cacheRemoveAll != null && cacheRemoveAll.afterInvocation(),
                cacheRemoveAll);
    }

    private Integer[] getKeyParameters(final List<Set<Annotation>> annotations)
    {
        final Collection<Integer> list = new ArrayList<>();
        int idx = 0;
        for (final Set<Annotation> set : annotations)
        {
            for (final Annotation a : set)
            {
                if (a.annotationType() == CacheKey.class)
                {
                    list.add(idx);
                }
            }
            idx++;
        }
        if (list.isEmpty())
        {
            for (int i = 0; i < annotations.size(); i++)
            {
                list.add(i);
            }
        }
        return list.toArray(new Integer[list.size()]);
    }

    private Integer getValueParameter(final List<Set<Annotation>> annotations)
    {
        final int idx = 0;
        for (final Set<Annotation> set : annotations)
        {
            for (final Annotation a : set)
            {
                if (a.annotationType() == CacheValue.class)
                {
                    return idx;
                }
            }
        }
        return -1;
    }

    private String defaultName(final Method method, final CacheDefaults defaults, final String cacheName)
    {
        if (!cacheName.isEmpty())
        {
            return cacheName;
        }
        if (defaults != null)
        {
            final String name = defaults.cacheName();
            if (!name.isEmpty())
            {
                return name;
            }
        }

        final StringBuilder name = new StringBuilder(method.getDeclaringClass().getName());
        name.append(".");
        name.append(method.getName());
        name.append("(");
        final Class<?>[] parameterTypes = method.getParameterTypes();
        for (int pIdx = 0; pIdx < parameterTypes.length; pIdx++)
        {
            name.append(parameterTypes[pIdx].getName());
            if ((pIdx + 1) < parameterTypes.length)
            {
                name.append(",");
            }
        }
        name.append(")");
        return name.toString();
    }

    private CacheDefaults findDefaults(final Class<?> targetType, final Method method)
    {
        if (Proxy.isProxyClass(targetType)) // target doesnt hold annotations
        {
            final Class<?> api = method.getDeclaringClass();
            for (final Class<?> type : targetType
                                         .getInterfaces())
            {
                if (!api.isAssignableFrom(type))
                {
                    continue;
                }
                return extractDefaults(type);
            }
        }
        return extractDefaults(targetType);
    }

    private CacheDefaults extractDefaults(final Class<?> type)
    {
        CacheDefaults annotation = null;
        Class<?> clazz = type;
        while (clazz != null && clazz != Object.class)
        {
            annotation = clazz.getAnnotation(CacheDefaults.class);
            if (annotation != null)
            {
                break;
            }
            clazz = clazz.getSuperclass();
        }
        return annotation;
    }

    public boolean isIncluded(final Class<?> aClass, final Class<?>[] in, final Class<?>[] out)
    {
        if (in.length == 0 && out.length == 0)
        {
            return false;
        }
        for (final Class<?> potentialIn : in)
        {
            if (potentialIn.isAssignableFrom(aClass))
            {
                for (final Class<?> potentialOut : out)
                {
                    if (potentialOut.isAssignableFrom(aClass))
                    {
                        return false;
                    }
                }
                return true;
            }
        }
        return false;
    }

    private CacheKeyGenerator cacheKeyGeneratorFor(final CacheDefaults defaults, final Class<? extends CacheKeyGenerator> cacheKeyGenerator)
    {
        if (!CacheKeyGenerator.class.equals(cacheKeyGenerator))
        {
            return instance(cacheKeyGenerator);
        }
        if (defaults != null)
        {
            final Class<? extends CacheKeyGenerator> defaultCacheKeyGenerator = defaults.cacheKeyGenerator();
            if (!CacheKeyGenerator.class.equals(defaultCacheKeyGenerator))
            {
                return instance(defaultCacheKeyGenerator);
            }
        }
        return defaultCacheKeyGenerator;
    }

    private CacheResolverFactory cacheResolverFactoryFor(final CacheDefaults defaults, final Class<? extends CacheResolverFactory> cacheResolverFactory)
    {
        if (!CacheResolverFactory.class.equals(cacheResolverFactory))
        {
            return instance(cacheResolverFactory);
        }
        if (defaults != null)
        {
            final Class<? extends CacheResolverFactory> defaultCacheResolverFactory = defaults.cacheResolverFactory();
            if (!CacheResolverFactory.class.equals(defaultCacheResolverFactory))
            {
                return instance(defaultCacheResolverFactory);
            }
        }
        return defaultCacheResolverFactory();
    }

    @SuppressWarnings("unchecked")
    private <T> T instance(final Class<T> type)
    {
        final Set<Bean<?>> beans = beanManager.getBeans(type);
        if (beans.isEmpty())
        {
            if (CacheKeyGenerator.class == type) {
                return (T) defaultCacheKeyGenerator;
            }
            if (CacheResolverFactory.class == type) {
                return (T) defaultCacheResolverFactory();
            }
            return null;
        }
        final Bean<?> bean = beanManager.resolve(beans);
        final CreationalContext<?> context = beanManager.createCreationalContext(bean);
        final Class<? extends Annotation> scope = bean.getScope();
        final boolean normalScope = beanManager.isNormalScope(scope);
        try
        {
            final Object reference = beanManager.getReference(bean, bean.getBeanClass(), context);
            if (!normalScope)
            {
                toRelease.add(context);
            }
            return (T) reference;
        }
        finally
        {
            if (normalScope)
            { // TODO: release at the right moment, @PreDestroy? question is: do we assume it is thread safe?
                context.release();
            }
        }
    }

    private CacheResolverFactoryImpl defaultCacheResolverFactory()
    {
        if (defaultCacheResolverFactory != null) {
            return defaultCacheResolverFactory;
        }
        synchronized (this) {
            if (defaultCacheResolverFactory != null) {
                return defaultCacheResolverFactory;
            }
            defaultCacheResolverFactory = new CacheResolverFactoryImpl();
        }
        return defaultCacheResolverFactory;
    }

    private Integer[] keyParameterIndexes(final Method method)
    {
        final List<Integer> keys = new LinkedList<>();
        final Annotation[][] parameterAnnotations = method.getParameterAnnotations();

        // first check if keys are specified explicitely
        for (int i = 0; i < method.getParameterTypes().length; i++)
        {
            final Annotation[] annotations = parameterAnnotations[i];
            for (final Annotation a : annotations)
            {
                if (a.annotationType().equals(CacheKey.class))
                {
                    keys.add(i);
                    break;
                }
            }
        }

        // if not then use all parameters but value ones
        if (keys.isEmpty())
        {
            for (int i = 0; i < method.getParameterTypes().length; i++)
            {
                final Annotation[] annotations = parameterAnnotations[i];
                boolean value = false;
                for (final Annotation a : annotations)
                {
                    if (a.annotationType().equals(CacheValue.class))
                    {
                        value = true;
                        break;
                    }
                }
                if (!value) {
                    keys.add(i);
                }
            }
        }
        return keys.toArray(new Integer[keys.size()]);
    }

    private static final class MethodKey
    {
        private final Class<?> base;
        private final Method delegate;
        private final int hash;

        private MethodKey(final Class<?> base, final Method delegate)
        {
            this.base = base; // we need a class to ensure inheritance don't fall in the same key
            this.delegate = delegate;
            this.hash = 31 * delegate.hashCode() + (base == null ? 0 : base.hashCode());
        }

        @Override
        public boolean equals(final Object o)
        {
            if (this == o)
            {
                return true;
            }
            if (o == null || getClass() != o.getClass())
            {
                return false;
            }
            final MethodKey classKey = MethodKey.class.cast(o);
            return delegate.equals(classKey.delegate) &&
                (base == null && classKey.base == null || base != null && base.equals(classKey.base));
        }

        @Override
        public int hashCode()
        {
            return hash;
        }
    }

    // TODO: split it in 5?
    public static class MethodMeta
    {
        private final Class<?>[] parameterTypes;
        private final List<Set<Annotation>> parameterAnnotations;
        private final Set<Annotation> annotations;
        private final Integer[] keysIndices;
        private final Integer valueIndex;
        private final Integer[] parameterIndices;

        private final String cacheResultCacheName;
        private final CacheResolverFactory cacheResultResolverFactory;
        private final CacheKeyGenerator cacheResultKeyGenerator;
        private final CacheResult cacheResult;

        private final String cachePutCacheName;
        private final CacheResolverFactory cachePutResolverFactory;
        private final CacheKeyGenerator cachePutKeyGenerator;
        private final boolean cachePutAfter;
        private final CachePut cachePut;

        private final String cacheRemoveCacheName;
        private final CacheResolverFactory cacheRemoveResolverFactory;
        private final CacheKeyGenerator cacheRemoveKeyGenerator;
        private final boolean cacheRemoveAfter;
        private final CacheRemove cacheRemove;

        private final String cacheRemoveAllCacheName;
        private final CacheResolverFactory cacheRemoveAllResolverFactory;
        private final boolean cacheRemoveAllAfter;
        private final CacheRemoveAll cacheRemoveAll;

        public MethodMeta(final Class<?>[] parameterTypes, final List<Set<Annotation>> parameterAnnotations, final Set<Annotation>
                annotations, final Integer[] keysIndices, final Integer valueIndex, final Integer[] parameterIndices, final String
                cacheResultCacheName, final CacheResolverFactory cacheResultResolverFactory, final CacheKeyGenerator
                cacheResultKeyGenerator, final CacheResult cacheResult, final String cachePutCacheName, final CacheResolverFactory
                cachePutResolverFactory, final CacheKeyGenerator cachePutKeyGenerator, final boolean cachePutAfter, final CachePut cachePut, final String
                cacheRemoveCacheName, final CacheResolverFactory cacheRemoveResolverFactory, final CacheKeyGenerator
                cacheRemoveKeyGenerator, final boolean cacheRemoveAfter, final CacheRemove cacheRemove, final String cacheRemoveAllCacheName,
                          final CacheResolverFactory cacheRemoveAllResolverFactory, final boolean
                                  cacheRemoveAllAfter, final CacheRemoveAll cacheRemoveAll)
        {
            this.parameterTypes = parameterTypes;
            this.parameterAnnotations = parameterAnnotations;
            this.annotations = annotations;
            this.keysIndices = keysIndices;
            this.valueIndex = valueIndex;
            this.parameterIndices = parameterIndices;
            this.cacheResultCacheName = cacheResultCacheName;
            this.cacheResultResolverFactory = cacheResultResolverFactory;
            this.cacheResultKeyGenerator = cacheResultKeyGenerator;
            this.cacheResult = cacheResult;
            this.cachePutCacheName = cachePutCacheName;
            this.cachePutResolverFactory = cachePutResolverFactory;
            this.cachePutKeyGenerator = cachePutKeyGenerator;
            this.cachePutAfter = cachePutAfter;
            this.cachePut = cachePut;
            this.cacheRemoveCacheName = cacheRemoveCacheName;
            this.cacheRemoveResolverFactory = cacheRemoveResolverFactory;
            this.cacheRemoveKeyGenerator = cacheRemoveKeyGenerator;
            this.cacheRemoveAfter = cacheRemoveAfter;
            this.cacheRemove = cacheRemove;
            this.cacheRemoveAllCacheName = cacheRemoveAllCacheName;
            this.cacheRemoveAllResolverFactory = cacheRemoveAllResolverFactory;
            this.cacheRemoveAllAfter = cacheRemoveAllAfter;
            this.cacheRemoveAll = cacheRemoveAll;
        }

        public boolean isCacheRemoveAfter()
        {
            return cacheRemoveAfter;
        }

        public boolean isCachePutAfter()
        {
            return cachePutAfter;
        }

        public Class<?>[] getParameterTypes()
        {
            return parameterTypes;
        }

        public List<Set<Annotation>> getParameterAnnotations()
        {
            return parameterAnnotations;
        }

        public String getCacheResultCacheName()
        {
            return cacheResultCacheName;
        }

        public CacheResolverFactory getCacheResultResolverFactory()
        {
            return cacheResultResolverFactory;
        }

        public CacheKeyGenerator getCacheResultKeyGenerator()
        {
            return cacheResultKeyGenerator;
        }

        public CacheResult getCacheResult() {
            return cacheResult;
        }

        public Integer[] getParameterIndices()
        {
            return parameterIndices;
        }

        public Set<Annotation> getAnnotations()
        {
            return annotations;
        }

        public Integer[] getKeysIndices()
        {
            return keysIndices;
        }

        public Integer getValuesIndex()
        {
            return valueIndex;
        }

        public Integer getValueIndex()
        {
            return valueIndex;
        }

        public String getCachePutCacheName()
        {
            return cachePutCacheName;
        }

        public CacheResolverFactory getCachePutResolverFactory()
        {
            return cachePutResolverFactory;
        }

        public CacheKeyGenerator getCachePutKeyGenerator()
        {
            return cachePutKeyGenerator;
        }

        public CachePut getCachePut()
        {
            return cachePut;
        }

        public String getCacheRemoveCacheName()
        {
            return cacheRemoveCacheName;
        }

        public CacheResolverFactory getCacheRemoveResolverFactory()
        {
            return cacheRemoveResolverFactory;
        }

        public CacheKeyGenerator getCacheRemoveKeyGenerator()
        {
            return cacheRemoveKeyGenerator;
        }

        public CacheRemove getCacheRemove()
        {
            return cacheRemove;
        }

        public String getCacheRemoveAllCacheName()
        {
            return cacheRemoveAllCacheName;
        }

        public CacheResolverFactory getCacheRemoveAllResolverFactory()
        {
            return cacheRemoveAllResolverFactory;
        }

        public boolean isCacheRemoveAllAfter()
        {
            return cacheRemoveAllAfter;
        }

        public CacheRemoveAll getCacheRemoveAll()
        {
            return cacheRemoveAll;
        }
    }
}
