/*****************************************************************
 *   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.cayenne.cache;

import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;

import org.apache.cayenne.CayenneRuntimeException;
import org.apache.cayenne.query.QueryMetadata;

import com.opensymphony.oscache.base.CacheEntry;
import com.opensymphony.oscache.base.NeedsRefreshException;
import com.opensymphony.oscache.general.GeneralCacheAdministrator;

/**
 * A {@link QueryCache} implementation based on OpenSymphony OSCache. Query cache
 * parameters are initialized from "/oscache.properties" file per <a
 * href="http://www.opensymphony.com/oscache/wiki/Configuration.html">OSCache</a>
 * documentation. In addition to the standard OSCache parameters, Cayenne provider allows
 * to setup global cache expiration parameters, and parameters matching the main query
 * cache group (i.e. the cache groups specified first). A sample oscache.properties may
 * look like this:
 * 
 * <pre>
 * # OSCache configuration file
 *                        
 * # OSCache standard configuration per
 * #     http://www.opensymphony.com/oscache/wiki/Configuration.html
 * # ---------------------------------------------------------------
 *                        
 * #cache.memory=true
 * #cache.blocking=false
 * cache.capacity=5000
 * cache.algorithm=com.opensymphony.oscache.base.algorithm.LRUCache
 *                        
 * # Cayenne specific properties
 * # ---------------------------------------------------------------
 *                        
 * # Default refresh period in seconds:
 * cayenne.default.refresh = 60
 *                        
 * # Default expiry specified as cron expressions per
 * #    http://www.opensymphony.com/oscache/wiki/Cron%20Expressions.html
 * # expire entries every hour on the 10's minute
 * cayenne.default.cron = 10 * * * *
 *                        
 * # Same parameters can be overriden per query
 * cayenne.group.xyz.refresh = 120
 * cayenne.group.xyz.cron = 10 1 * * *
 * </pre>
 * 
 * Further extension of OSQueryCache is possible by using OSCache listener API.
 * 
 * @since 3.0
 */
public class OSQueryCache implements QueryCache {

    public static final int DEFAULT_REFRESH_PERIOD = CacheEntry.INDEFINITE_EXPIRY;

    static String DEFAULT_REFRESH_KEY = "cayenne.default.refresh";
    static String DEFAULT_CRON_KEY = "cayenne.default.cron";

    static String GROUP_PREFIX = "cayenne.group.";
    static String REFRESH_SUFFIX = ".refresh";
    static String CRON_SUFFIX = ".cron";

    protected GeneralCacheAdministrator osCache;

    RefreshSpecification defaultRefreshSpecification;
    Map<String, RefreshSpecification> refreshSpecifications;
    Properties properties;

    public OSQueryCache() {
        OSCacheAdministrator admin = new OSCacheAdministrator();
        init(admin, admin.getProperties());
    }

    public OSQueryCache(GeneralCacheAdministrator cache, Properties properties) {
        init(cache, properties);
    }

    /**
     * Returns a collection of group names that have been configured explicitly via
     * properties.
     */
    @SuppressWarnings("unchecked")
    public Collection getGroupNames() {
        return refreshSpecifications != null
                ? Collections.unmodifiableCollection(refreshSpecifications.keySet())
                : Collections.EMPTY_SET;
    }

    public String getCronExpression(String groupName) {

        RefreshSpecification spec = null;

        if (refreshSpecifications != null) {
            spec = refreshSpecifications.get(groupName);
        }

        if (spec == null) {
            spec = defaultRefreshSpecification;
        }

        return spec.cronExpression;
    }

    public int getRrefreshPeriod(String groupName) {

        RefreshSpecification spec = null;

        if (refreshSpecifications != null) {
            spec = refreshSpecifications.get(groupName);
        }

        if (spec == null) {
            spec = defaultRefreshSpecification;
        }

        return spec.refreshPeriod;
    }

    /**
     * Returns the underlying OSCache manager object.
     */
    public GeneralCacheAdministrator getOsCache() {
        return osCache;
    }

    /**
     * Returns configuration properties. Usually this is the contents of
     * "oscache.properties" file.
     */
    public Properties getProperties() {
        return properties;
    }

    void init(GeneralCacheAdministrator cache, Properties properties) {
        this.properties = properties;
        this.osCache = cache;
        this.defaultRefreshSpecification = new RefreshSpecification();

        // load defaults and per-query settings
        if (properties != null) {

            // first extract defaults...
            String defaultRefresh = properties.getProperty(DEFAULT_REFRESH_KEY);
            if (defaultRefresh != null) {
                defaultRefreshSpecification.setRefreshPeriod(defaultRefresh);
            }

            String defaultCron = properties.getProperty(DEFAULT_CRON_KEY);
            if (defaultCron != null) {
                defaultRefreshSpecification.cronExpression = defaultCron;
            }

            // now check for per-query settings
            for (final Map.Entry<Object, Object> entry : properties.entrySet()) {

                if (entry.getKey() == null || entry.getValue() == null) {
                    continue;
                }

                String key = entry.getKey().toString();
                if (key.startsWith(GROUP_PREFIX)) {

                    if (key.endsWith(REFRESH_SUFFIX)) {
                        String name = key.substring(GROUP_PREFIX.length(), key.length()
                                - REFRESH_SUFFIX.length());

                        initRefreshPolicy(name, entry.getValue());
                    }
                    else if (key.endsWith(CRON_SUFFIX)) {
                        String name = key.substring(GROUP_PREFIX.length(), key.length()
                                - CRON_SUFFIX.length());

                        initCronPolicy(name, entry.getValue());
                    }
                }
            }
        }
    }

    /**
     * Called internally for each group that is configured with cron policy in the
     * properties. Exposed mainly for the benefit of subclasses. When overriding, call
     * 'super'.
     */
    protected void initCronPolicy(String groupName, Object value) {
        nonNullSpec(groupName).cronExpression = value != null ? value.toString() : null;
    }

    /**
     * Called internally for each group that is configured with refresh policy in the
     * properties. Exposed mainly for the benefit of subclasses. When overriding, call
     * 'super'.
     */
    protected void initRefreshPolicy(String groupName, Object value) {
        nonNullSpec(groupName).setRefreshPeriod(value);
    }

    private RefreshSpecification nonNullSpec(String name) {
        if (refreshSpecifications == null) {
            refreshSpecifications = new HashMap<String, RefreshSpecification>();
        }

        RefreshSpecification spec = refreshSpecifications.get(name);
        if (spec == null) {
            spec = new RefreshSpecification();
            spec.cronExpression = defaultRefreshSpecification.cronExpression;
            spec.refreshPeriod = defaultRefreshSpecification.refreshPeriod;
            refreshSpecifications.put(name, spec);
        }

        return spec;
    }

    @SuppressWarnings("unchecked")
    public List get(QueryMetadata metadata) {
        String key = metadata.getCacheKey();
        if (key == null) {
            return null;
        }

        RefreshSpecification refresh = getRefreshSpecification(metadata);

        try {
            return (List) osCache.getFromCache(
                    key,
                    refresh.refreshPeriod,
                    refresh.cronExpression);
        }
        catch (NeedsRefreshException e) {
            osCache.cancelUpdate(key);
            return null;
        }
    }

    /**
     * Returns a non-null cached value. If it is not present in the cache, it is obtained
     * by calling {@link QueryCacheEntryFactory#createObject()}. Whether the cache provider
     * will block on the entry update or not is controlled by "cache.blocking"
     * configuration property and is "false" by default.
     */
    @SuppressWarnings("unchecked")
    public List get(QueryMetadata metadata, QueryCacheEntryFactory factory) {
        String key = metadata.getCacheKey();
        if (key == null) {
            return null;
        }

        RefreshSpecification refresh = getRefreshSpecification(metadata);

        try {
            return (List) osCache.getFromCache(
                    key,
                    refresh.refreshPeriod,
                    refresh.cronExpression);
        }
        catch (NeedsRefreshException e) {
            boolean updated = false;
            try {
                Object result = factory.createObject();

                if (!(result instanceof List)) {
                    if (result == null) {
                        throw new CayenneRuntimeException("Null on cache rebuilding: "
                                + metadata.getCacheKey());
                    }
                    else {
                        throw new CayenneRuntimeException(
                                "Invalid query result, expected List, got "
                                        + result.getClass().getName());
                    }
                }

                List list = (List) result;

                put(metadata, list);
                updated = true;
                return list;
            }
            finally {
                if (!updated) {
                    // It is essential that cancelUpdate is called if the
                    // cached content could not be rebuilt
                    osCache.cancelUpdate(key);
                }
            }
        }
    }
    
    /**
     * Returns non-null RefreshSpecification for the QueryMetadata.
     */
    RefreshSpecification getRefreshSpecification(QueryMetadata metadata) {

        RefreshSpecification refresh = null;

        if (refreshSpecifications != null) {
            String[] groups = metadata.getCacheGroups();
            if (groups != null && groups.length > 0) {
                refresh = refreshSpecifications.get(groups[0]);
            }
        }

        return refresh != null ? refresh : defaultRefreshSpecification;
    }

    @SuppressWarnings("unchecked")
    public void put(QueryMetadata metadata, List results) {
        String key = metadata.getCacheKey();
        if (key != null) {
            osCache.putInCache(key, results, metadata.getCacheGroups());
        }
    }

    public void remove(String key) {
        if (key != null) {
            osCache.removeEntry(key);
        }
    }

    public void removeGroup(String groupKey) {
        if (groupKey != null) {
            osCache.flushGroup(groupKey);
        }
    }

    public void clear() {
        osCache.flushAll();
    }

    public int size() {
        return osCache.getCache().getSize();
    }

    public int capacity() {
        return osCache.getCache().getCapacity();
    }

    final static class RefreshSpecification {

        int refreshPeriod;
        String cronExpression;

        RefreshSpecification() {
            this.refreshPeriod = DEFAULT_REFRESH_PERIOD;
        }

        RefreshSpecification(int refrehsPeriod, String cronExpression) {
            this.refreshPeriod = refrehsPeriod;
            this.cronExpression = cronExpression;
        }

        void setRefreshPeriod(Object value) {
            try {
                refreshPeriod = Integer.parseInt(value.toString());
            }
            catch (NumberFormatException e) {
                // ignore...
            }
        }
    }

    final static class OSCacheAdministrator extends GeneralCacheAdministrator {
        OSCacheAdministrator() {
        }

        OSCacheAdministrator(Properties properties) {
            super(properties);
        }

        Properties getProperties() {
            return super.config.getProperties();
        }
    }
}
