/**
 * 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:
 *
 *   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.apereo.portal.utils.cache.resource;

import java.io.IOException;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import net.sf.ehcache.Ehcache;
import net.sf.ehcache.Element;
import net.sf.ehcache.constructs.blocking.SelfPopulatingCache;

import org.apereo.portal.utils.cache.ThreadLocalCacheEntryFactory;
import org.jasig.resourceserver.aggr.om.Included;
import org.jasig.resourceserver.utils.aggr.ResourcesElementsProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

/**
 * Uses an {@link Ehcache} to handle caching of the resources.
 * 
 * @author Eric Dalquist
 * @version $Revision$
 */
@Service
public class CachingResourceLoaderImpl implements CachingResourceLoader {
    protected final Logger logger = LoggerFactory.getLogger(this.getClass());
    
    private final CachedResourceEntryFactory entryFactory = new CachedResourceEntryFactory();
    
    private long checkInterval = TimeUnit.MINUTES.toMillis(1);
    
    private Ehcache resourceCache;
    private ResourcesElementsProvider resourcesElementsProvider;

    @Autowired
    public void setResourceCache(
            @Qualifier("org.apereo.portal.utils.cache.resource.CachingResourceLoader") Ehcache resourceCache) {
        this.resourceCache = new SelfPopulatingCache(resourceCache, this.entryFactory);
    }
    
    @Autowired
    public void setResourcesElementsProvider(ResourcesElementsProvider resourcesElementsProvider) {
        this.resourcesElementsProvider = resourcesElementsProvider;
    }

    /**
     * How frequently the resource should be checked for updates (in ms). Defaults to 1 minute.
     */
    public void setCheckInterval(long checkInterval) {
        this.checkInterval = checkInterval;
    }


    /* (non-Javadoc)
     * @see org.apereo.portal.utils.cache.CachingResourceLoader#getResource(org.springframework.core.io.Resource, org.apereo.portal.utils.cache.ResourceBuilder)
     */
    @Override
    public <T> CachedResource<T> getResource(Resource resource, Loader<T> builder) throws IOException {
        return this.getResource(resource, builder, this.checkInterval);
    }

    /* (non-Javadoc)
     * @see org.apereo.portal.utils.cache.CachingResourceLoader#getResource(org.springframework.core.io.Resource, org.apereo.portal.utils.cache.ResourceBuilder, org.apereo.portal.utils.cache.ResourceLoaderOptions)
     */
    @Override
    public <T> CachedResource<T> getResource(Resource resource, Loader<T> builder, long checkInterval) throws IOException {
        if (Included.PLAIN == this.resourcesElementsProvider.getDefaultIncludedType()) {
            this.logger.trace("Resoure Aggregation Disabled, ignoring resource cache and loading '" + resource + "' directly");
            return this.loadResource(resource, builder);
        }
        
        //Look for the resource in the cache, since it has been wrapped with a SelfPopulatingCache it should never return null.
        final GetResourceArguments<T> arguments = new GetResourceArguments<T>(resource, builder);
        final Element element = this.entryFactory.getWithData(this.resourceCache, resource, arguments);

        CachedResource<T> cachedResource = (CachedResource<T>)element.getObjectValue();
        if (this.logger.isTraceEnabled()) {
            this.logger.trace("Found " + cachedResource + " in cache");
        }
        
        //Found it, now check if the last-load time is within the check interval
        final long lastCheckTime = cachedResource.getLastCheckTime();
        if (lastCheckTime + checkInterval >= System.currentTimeMillis()) {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace(cachedResource + " is within checkInterval " + checkInterval + ", returning");
            }
            return cachedResource;
        }
        if (this.logger.isTraceEnabled()) {
            this.logger.trace(cachedResource + " is older than checkInterval " + checkInterval + ", checking for modification");
        }

        //If the resource has not been modified return the cached resource. 
        final boolean resourceModified = this.checkIfModified(cachedResource);
        if (!resourceModified) {
            cachedResource.setLastCheckTime(System.currentTimeMillis());
            this.resourceCache.put(element); //do a cache put to notify the cache the object has been modified
            return cachedResource;
        }
        
        //The resource has been modified, reload it.
        cachedResource = this.loadResource(resource, builder);
        
        //Cache the loaded resource
        this.resourceCache.put(new Element(resource, cachedResource));
        
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Loaded and cached " + cachedResource);
        }
        
        return cachedResource;
    }
    
    /**
     * Check if any of the Resources used to load the {@link CachedResource} have
     * been modified.
     */
    protected <T> boolean checkIfModified(CachedResource<T> cachedResource) {
        final Resource resource = cachedResource.getResource();

        //Check if the resource has been modified since it was last loaded.
        final long lastLoadTime = cachedResource.getLastLoadTime();
        final long mainLastModified = this.getLastModified(resource);
        boolean resourceModified = lastLoadTime < mainLastModified;
        if (resourceModified) {
            this.logger.trace("Resource {} was modified at {}, reloading", 
                    new Object[] {resource, mainLastModified});
            return true;
        }
        
        //If the main resource hasn't changed check additional resources for modifications
        for (final Map.Entry<Resource, Long> additionalResourceEntry : cachedResource.getAdditionalResources().entrySet()) {
            final Resource additionalResource = additionalResourceEntry.getKey();
            final Long resourceLastLoadTime = additionalResourceEntry.getValue();
            
            final long lastModified = this.getLastModified(additionalResource);
            if (resourceLastLoadTime < lastModified) {
                this.logger.trace("Additional resource {} for {} was modified at {}, reloading", 
                        new Object[] {additionalResource, resource, lastModified});
                return true;
            }
        }
        
        this.logger.trace("{} has not been modified since last loaded {}, returning", cachedResource, lastLoadTime);
        return false;
    }
    
    /**
     * Determine the last modified time stamp for the resource
     */
    protected long getLastModified(Resource resource) {
        try {
            return resource.lastModified();
        }
        catch (IOException e) {
            this.logger.warn("Could not determine lastModified for " + resource + ". This resource will never be reloaded due to lastModified changing.", e);
        }
        
        return 0;
    }

    private <T> CachedResource<T> loadResource(Resource resource, Loader<T> builder) throws IOException {
        final long lastLoadTime = System.currentTimeMillis();

        long lastModified = 0;
        try {
            lastModified = resource.lastModified();
        }
        catch (IOException e) {
            //Ignore, not all resources can have a valid lastModified returned
        }
        
        //Build the resource using the callback
        final LoadedResource<T> loadedResource = builder.loadResource(resource);
        
        final Serializable cacheKey = (Serializable)Arrays.asList(lastModified, loadedResource.getAdditionalResources());
        
        //Create the CachedResource based on if digesting was enabled
        return new CachedResourceImpl<T>(resource, loadedResource, lastLoadTime, cacheKey);
    }
    
    private static class GetResourceArguments<T> {
        public final Resource resource;
        public final Loader<T> builder;

        public GetResourceArguments(Resource resource, Loader<T> builder) {
            this.resource = resource;
            this.builder = builder;
        }
    }
    
    private class CachedResourceEntryFactory extends ThreadLocalCacheEntryFactory<GetResourceArguments<?>> {

        @Override
        protected Object createEntry(Object key, GetResourceArguments<?> threadData) throws Exception {
            return loadResource(threadData.resource, threadData.builder);
        }
    }
}
