/*
 * Copyright (c) 2008, Harald Kuhr
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * * Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 *
 * * Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 *
 * * Neither the name of the copyright holder nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package com.twelvemonkeys.servlet.cache;

import com.twelvemonkeys.io.FileUtil;
import com.twelvemonkeys.lang.StringUtil;
import com.twelvemonkeys.lang.Validate;
import com.twelvemonkeys.net.HTTPUtil;
import com.twelvemonkeys.net.MIMEUtil;
import com.twelvemonkeys.util.LRUHashMap;
import com.twelvemonkeys.util.NullMap;

import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * A "simple" HTTP cache.
 *
 * - Use a mix of parameters and hashcode + lenght with fixed (max) lenght?
 * (Hashcodes of Strings are constant).
 * - Store full filenames in .vary, instead of just extension, and use
 * short filenames? (and only one .vary per dir).
 * <p>
 * <!-- TODO: Fix the caching algortim
 * Squid-2 algorithm
 * <p>
 * For Squid-2 the refresh algorithm has been slightly modified to give the EXPIRES value a higher precedence, and the CONF_MIN value lower precedence:
 * </p>
 * <p>
 * if (EXPIRES) {
 * if (EXPIRES <= NOW)
 * return STALE
 * else
 * return FRESH
 * }
 * if (CLIENT_MAX_AGE)
 * if (OBJ_AGE > CLIENT_MAX_AGE)
 * return STALE
 * if (OBJ_AGE > CONF_MAX)
 * return STALE
 * if (OBJ_DATE > OBJ_LASTMOD) {
 * if (LM_FACTOR < CONF_PERCENT)
 * return FRESH
 * else
 * return STALE
 * }
 * if (OBJ_AGE <= CONF_MIN)
 * return FRESH
 * return STALE
 * </p>
 * -->
 * </p>
 *
 * @author <a href="mailto:harald.kuhr@gmail.com">Harald Kuhr</a>
 * @version $Id: HTTPCache.java#4 $
 */
// TODO: OMPTIMIZE: Cache parsed vary-info objects, not the properties-files
// TODO: BUG: Better filename handling, as some filenames become too long..
// TODO: TEST: Battle-testing using some URL-hammer tool and maybe a profiler
// TODO: ETag/Conditional (If-None-Match) support!
// TODO: Rewrite to use java.util.concurrent Locks (if possible) for performance
//       Maybe use ConcurrentHashMap instead fo synchronized HashMap?
// TODO: Rewrite to use NIO for performance
// TODO: Allow no tempdir for in-memory only cache
// TODO: Specify max size of disk-cache
public class HTTPCache {
    /**
     * The HTTP header {@code "Cache-Control"}
     */
    protected static final String HEADER_CACHE_CONTROL = "Cache-Control";
    /**
     * The HTTP header {@code "Content-Type"}
     */
    protected static final String HEADER_CONTENT_TYPE = "Content-Type";
    /**
     * The HTTP header {@code "Date"}
     */
    protected static final String HEADER_DATE = "Date";
    /**
     * The HTTP header {@code "ETag"}
     */
    protected static final String HEADER_ETAG = "ETag";
    /**
     * The HTTP header {@code "Expires"}
     */
    protected static final String HEADER_EXPIRES = "Expires";
    /**
     * The HTTP header {@code "If-Modified-Since"}
     */
    protected static final String HEADER_IF_MODIFIED_SINCE = "If-Modified-Since";
    /**
     * The HTTP header {@code "If-None-Match"}
     */
    protected static final String HEADER_IF_NONE_MATCH = "If-None-Match";
    /**
     * The HTTP header {@code "Last-Modified"}
     */
    protected static final String HEADER_LAST_MODIFIED = "Last-Modified";
    /**
     * The HTTP header {@code "Pragma"}
     */
    protected static final String HEADER_PRAGMA = "Pragma";
    /**
     * The HTTP header {@code "Vary"}
     */
    protected static final String HEADER_VARY = "Vary";
    /**
     * The HTTP header {@code "Warning"}
     */
    protected static final String HEADER_WARNING = "Warning";
    /**
     * HTTP extension header {@code "X-Cached-At"}
     */
    protected static final String HEADER_CACHED_TIME = "X-Cached-At";

    /**
     * The file extension for header files ({@code ".headers"})
     */
    protected static final String FILE_EXT_HEADERS = ".headers";
    /**
     * The file extension for varation-info files ({@code ".vary"})
     */
    protected static final String FILE_EXT_VARY = ".vary";

    /**
     * The directory used for the disk-based cache
     */
    private File tempDir;

    /**
     * Indicates wether the disk-based cache should be deleted when the
     * container shuts down/VM exits
     */
    private boolean deleteCacheOnExit;

    /**
     * In-memory content cache
     */
    private final Map<String, CachedResponse> contentCache;
    /**
     * In-memory enity cache
     */
    private final Map<String, CachedEntity> entityCache;
    /**
     * In-memory varyiation-info cache
     */
    private final Map<String, Properties> varyCache;

    private long defaultExpiryTime = -1;

    private final Logger logger;

    // Internal constructor for sublcasses only
    protected HTTPCache(
            final File pTempFolder,
            final long pDefaultCacheExpiryTime,
            final int pMaxMemCacheSize,
            final int pMaxCachedEntites,
            final boolean pDeleteCacheOnExit,
            final Logger pLogger
    ) {
        Validate.notNull(pTempFolder, "temp folder");
        Validate.isTrue(pTempFolder.exists() || pTempFolder.mkdirs(), pTempFolder.getAbsolutePath(), "Could not create required temp directory: %s");
        Validate.isTrue(pTempFolder.canRead() && pTempFolder.canWrite(), pTempFolder.getAbsolutePath(), "Must have read/write access to temp folder: %s");

        Validate.isTrue(pDefaultCacheExpiryTime >= 0, pDefaultCacheExpiryTime, "Negative expiry time: %d");
        Validate.isTrue(pMaxMemCacheSize >= 0, pDefaultCacheExpiryTime, "Negative maximum memory cache size: %d");
        Validate.isTrue(pMaxCachedEntites >= 0, pDefaultCacheExpiryTime, "Negative maximum number of cached entries: %d");

        defaultExpiryTime = pDefaultCacheExpiryTime;

        if (pMaxMemCacheSize > 0) {
//            Map backing = new SizedLRUMap(pMaxMemCacheSize); // size in bytes
//            contentCache = new TimeoutMap(backing, null, pDefaultCacheExpiryTime);
            contentCache = new SizedLRUMap<String, CachedResponse>(pMaxMemCacheSize); // size in bytes
        }
        else {
            contentCache = new NullMap<String, CachedResponse>();
        }

        entityCache = new LRUHashMap<String, CachedEntity>(pMaxCachedEntites);
        varyCache = new LRUHashMap<String, Properties>(pMaxCachedEntites);

        deleteCacheOnExit = pDeleteCacheOnExit;

        tempDir = pTempFolder;

        logger = pLogger != null ? pLogger : Logger.getLogger(getClass().getName());
    }

    /**
     * Creates an {@code HTTPCache}.
     *
     * @param pTempFolder             the temp folder for this cache.
     * @param pDefaultCacheExpiryTime Default expiry time for cached entities,
     *                                {@code &gt;= 0}
     * @param pMaxMemCacheSize        Maximum size of in-memory cache for content
     *                                <em>in bytes</em>, {@code &gt;= 0} ({@code 0} means no
     *                                in-memory cache)
     * @param pMaxCachedEntites       Maximum number of entities in cache
     * @param pDeleteCacheOnExit      specifies wether the file cache should be
     *                                deleted when the application or VM shuts down
     * @throws IllegalArgumentException if {@code pName} or {@code pContext} is
     *                                  {@code null} or if any of {@code pDefaultCacheExpiryTime},
     *                                  {@code pMaxMemCacheSize} or {@code pMaxCachedEntites} are
     *                                  negative,
     *                                  or if the directory as given in the context attribute
     *                                  {@code "javax.servlet.context.tempdir"} does not exist, and
     *                                  cannot be created.
     */
    public HTTPCache(final File pTempFolder,
                        final long pDefaultCacheExpiryTime,
                        final int pMaxMemCacheSize, final int pMaxCachedEntites,
                        final boolean pDeleteCacheOnExit) {
        this(pTempFolder, pDefaultCacheExpiryTime, pMaxMemCacheSize, pMaxCachedEntites, pDeleteCacheOnExit, null);
    }


    /**
     * Creates an {@code HTTPCache}.
     *
     * @param pName                   Name of this cache (should be unique per application).
     *                                Used for temp folder
     * @param pContext                Servlet context for the application.
     * @param pDefaultCacheExpiryTime Default expiry time for cached entities,
     *                                {@code &gt;= 0}
     * @param pMaxMemCacheSize        Maximum size of in-memory cache for content
     *                                <em>in bytes</em>, {@code &gt;= 0} ({@code 0} means no
     *                                in-memory cache)
     * @param pMaxCachedEntites       Maximum number of entities in cache
     * @param pDeleteCacheOnExit      specifies wether the file cache should be
     *                                deleted when the application or VM shuts down
     * @throws IllegalArgumentException if {@code pName} or {@code pContext} is
     *                                  {@code null} or if any of {@code pDefaultCacheExpiryTime},
     *                                  {@code pMaxMemCacheSize} or {@code pMaxCachedEntites} are
     *                                  negative,
     *                                  or if the directory as given in the context attribute
     *                                  {@code "javax.servlet.context.tempdir"} does not exist, and
     *                                  cannot be created.
     * @deprecated Use {@link #HTTPCache(File, long, int, int, boolean)} instead.
     */
    public HTTPCache(final String pName, final ServletContext pContext,
            final int pDefaultCacheExpiryTime, final int pMaxMemCacheSize,
            final int pMaxCachedEntites, final boolean pDeleteCacheOnExit) {
        this(
                getTempFolder(pName, pContext),
                pDefaultCacheExpiryTime, pMaxMemCacheSize, pMaxCachedEntites, pDeleteCacheOnExit,
                new CacheFilter.ServletContextLoggerAdapter(pName, pContext)
        );
    }

    private static File getTempFolder(String pName, ServletContext pContext) {
        Validate.notNull(pName, "name");
        Validate.isTrue(!StringUtil.isEmpty(pName), pName, "empty name: '%s'");
        Validate.notNull(pContext, "context");

        File tempRoot = (File) pContext.getAttribute("javax.servlet.context.tempdir");
        if (tempRoot == null) {
            throw new IllegalStateException("Missing context attribute \"javax.servlet.context.tempdir\"");
        }

        return new File(tempRoot, pName);
    }

    public String toString() {
        StringBuilder buf = new StringBuilder(getClass().getSimpleName());
        buf.append("[");
        buf.append("Temp dir: ");
        buf.append(tempDir.getAbsolutePath());
        if (deleteCacheOnExit) {
            buf.append(" (non-persistent)");
        }
        else {
            buf.append(" (persistent)");            
        }
        buf.append(", EntityCache: {");
        buf.append(entityCache.size());
        buf.append(" entries in a ");
        buf.append(entityCache.getClass().getName());
        buf.append("}, VaryCache: {");
        buf.append(varyCache.size());
        buf.append(" entries in a ");
        buf.append(varyCache.getClass().getName());
        buf.append("}, ContentCache: {");
        buf.append(contentCache.size());
        buf.append(" entries in a ");
        buf.append(contentCache.getClass().getName());
        buf.append("}]");

        return buf.toString();
    }

    void log(final String pMessage) {
        logger.log(Level.INFO, pMessage);
    }

    void log(final String pMessage, Throwable pException) {
        logger.log(Level.WARNING, pMessage, pException);
    }

    /**
     * Looks up the {@code CachedEntity} for the given request.
     *
     * @param pRequest the request
     * @param pResponse the response
     * @param pResolver the resolver
     * @throws java.io.IOException if an I/O error occurs
     * @throws CacheException if the cached entity can't be resolved for some reason
     */
    public void doCached(final CacheRequest pRequest, final CacheResponse pResponse, final ResponseResolver pResolver) throws IOException, CacheException {
        // TODO: Expire cached items on PUT/POST/DELETE/PURGE
        // If not cachable request, resolve directly
        if (!isCacheable(pRequest)) {
            pResolver.resolve(pRequest, pResponse);
        }
        else {
            // Generate cacheURI
            String cacheURI = generateCacheURI(pRequest);
//             System.out.println(" ## HTTPCache ## Request Id (cacheURI): " + cacheURI);

            // Get/create cached entity
            CachedEntity cached;
            synchronized (entityCache) {
                cached = entityCache.get(cacheURI);
                if (cached == null) {
                    cached = new CachedEntityImpl(cacheURI, this);
                    entityCache.put(cacheURI, cached);
                }
            }

            // else if (not cached || stale), resolve through wrapped (caching) response
            // else render to response

            // TODO: This is a bottleneck for uncachable resources. Should not
            // synchronize, if we know (HOW?) the resource is not cachable.
            synchronized (cached) {
                if (cached.isStale(pRequest) /* TODO: NOT CACHED?! */) {
                    // Go fetch...
                    WritableCachedResponse cachedResponse = cached.createCachedResponse();
                    pResolver.resolve(pRequest, cachedResponse);

                    if (isCachable(cachedResponse)) {
//                        System.out.println("Registering content: " + cachedResponse.getCachedResponse());
                        registerContent(cacheURI, pRequest, cachedResponse.getCachedResponse());
                    }
                    else {
                        // TODO: What about non-cachable responses? We need to either remove them from cache, or mark them as stale...
                        // Best is probably to mark as non-cacheable for later, and NOT store content (performance)
//                        System.out.println("Non-cacheable response: " + cachedResponse);

                        // TODO: Write, but should really do this unbuffered.... And some resolver might be able to do just that?
                        // Might need a resolver.isWriteThroughForUncachableResources() method...
                        pResponse.setStatus(cachedResponse.getStatus());
                        cachedResponse.writeHeadersTo(pResponse);
                        cachedResponse.writeContentsTo(pResponse.getOutputStream());
                        return;
                    }
                }
            }

            cached.render(pRequest, pResponse);
        }
    }

    protected void invalidate(CacheRequest pRequest) {
        // Generate cacheURI
        String cacheURI = generateCacheURI(pRequest);

        // Get/create cached entity
        CachedEntity cached;
        synchronized (entityCache) {
            cached = entityCache.get(cacheURI);
            if (cached != null) {
                // TODO; Remove all variants
                entityCache.remove(cacheURI);
            }
        }

    }

    private boolean isCacheable(final CacheRequest pRequest) {
        // TODO: Support public/private cache (a cache probably have to be one of the two, when created)
        // TODO: Only private caches should cache requests with Authorization

        // TODO: OptimizeMe!
        // It's probably best to cache the "cacheableness" of a request and a resource separately
        List<String> cacheControlValues = pRequest.getHeaders().get(HEADER_CACHE_CONTROL);
        if (cacheControlValues != null) {
            Map<String, String> cacheControl = new HashMap<String, String>();
            for (String cc : cacheControlValues) {
                List<String> directives = Arrays.asList(cc.split(","));
                for (String directive : directives) {
                    directive = directive.trim();
                    if (directive.length() > 0) {
                        String[] directiveParts = directive.split("=", 2);
                        cacheControl.put(directiveParts[0], directiveParts.length > 1 ? directiveParts[1] : null);
                    }
                }
            }

            if (cacheControl.containsKey("no-cache") || cacheControl.containsKey("no-store")) {
                return false;
            }

            /*
            "no-cache"                          ; Section 14.9.1
         | "no-store"                          ; Section 14.9.2
         | "max-age" "=" delta-seconds         ; Section 14.9.3, 14.9.4
         | "max-stale" [ "=" delta-seconds ]   ; Section 14.9.3
         | "min-fresh" "=" delta-seconds       ; Section 14.9.3
         | "no-transform"                      ; Section 14.9.5
         | "only-if-cached"
             */
        }

        return true;
    }

    private boolean isCachable(final CacheResponse pResponse) {
        if (pResponse.getStatus() != HttpServletResponse.SC_OK) {
            return false;
        }

        // Vary: *
        List<String> values = pResponse.getHeaders().get(HTTPCache.HEADER_VARY);
        if (values != null) {
            for (String value : values) {
                if ("*".equals(value)) {
                    return false;
                }
            }
        }

        // Cache-Control: no-cache, no-store, must-revalidate
        values = pResponse.getHeaders().get(HTTPCache.HEADER_CACHE_CONTROL);
        if (values != null) {
            for (String value : values) {
                if (StringUtil.contains(value, "no-cache")
                        || StringUtil.contains(value, "no-store")
                        || StringUtil.contains(value, "must-revalidate")) {
                    return false;
                }
            }
        }

        // Pragma: no-cache
        values = pResponse.getHeaders().get(HTTPCache.HEADER_PRAGMA);
        if (values != null) {
            for (String value : values) {
                if (StringUtil.contains(value, "no-cache")) {
                    return false;
                }
            }
        }

        return true;
    }


    /**
     * Allows a server-side cache mechanism to peek at the real file.
     * Default implementation return {@code null}.
     *
     * @param pRequest the request
     * @return {@code null}, always
     */
    protected File getRealFile(final CacheRequest pRequest) {
        // TODO: Create callback for this? Only possible for server-side cache... Maybe we can get away without this?
        // For now: Default implementation that returns null
        return null;
/*
        String contextRelativeURI = ServletUtil.getContextRelativeURI(pRequest);
        // System.out.println(" ## HTTPCache ## Context relative URI: " + contextRelativeURI);

        String path = mContext.getRealPath(contextRelativeURI);
        // System.out.println(" ## HTTPCache ## Real path: " + path);

        if (path != null) {
            return new File(path);
        }

        return null;
*/
    }

    private File getCachedFile(final String pCacheURI, final CacheRequest pRequest) {
        File file = null;

        // Get base dir
        File base = new File(tempDir, "./" + pCacheURI);
        final String basePath = base.getAbsolutePath();
        File directory = base.getParentFile();

        // Get list of files that are candidates
        File[] candidates = directory.listFiles(new FileFilter() {
            public boolean accept(File pFile) {
                return pFile.getAbsolutePath().startsWith(basePath)
                        && !pFile.getName().endsWith(FILE_EXT_HEADERS)
                        && !pFile.getName().endsWith(FILE_EXT_VARY);
            }
        });

        // Negotiation
        if (candidates != null) {
            String extension = getVaryExtension(pCacheURI, pRequest);
            //System.out.println("-- Vary ext: " + extension);
            if (extension != null) {
                for (File candidate : candidates) {
                    //System.out.println("-- Candidate: " + candidates[i]);

                    if (extension.equals("ANY") || extension.equals(FileUtil.getExtension(candidate))) {
                        //System.out.println("-- Candidate selected");
                        file = candidate;
                        break;
                    }
                }
            }
        }
        else if (base.exists()) {
            //System.out.println("-- File not a directory: " + directory);
            log("File not a directory: " + directory);
        }

        return file;
    }

    private String getVaryExtension(final String pCacheURI, final CacheRequest pRequest) {
        Properties variations = getVaryProperties(pCacheURI);

        String[] varyHeaders = StringUtil.toStringArray(variations.getProperty(HEADER_VARY, ""));
//        System.out.println("-- Vary: \"" + variations.getProperty(HEADER_VARY) + "\"");

        String varyKey = createVaryKey(varyHeaders, pRequest);
//        System.out.println("-- Vary key: \"" + varyKey + "\"");

        // If no vary, just go with any version...
        return StringUtil.isEmpty(varyKey) ? "ANY" : variations.getProperty(varyKey, null);
    }

    private String createVaryKey(final String[] pVaryHeaders, final CacheRequest pRequest) {
        if (pVaryHeaders == null) {
            return null;
        }

        StringBuilder headerValues = new StringBuilder();
        for (String varyHeader : pVaryHeaders) {
            List<String> varies = pRequest.getHeaders().get(varyHeader);
            String headerValue = varies != null && varies.size() > 0 ? varies.get(0) : null;

            headerValues.append(varyHeader);
            headerValues.append("__V_");
            headerValues.append(createSafeHeader(headerValue));
        }

        return headerValues.toString();
    }

    private void storeVaryProperties(final String pCacheURI, final Properties pVariations) {
        synchronized (pVariations) {
            try {
                File file = getVaryPropertiesFile(pCacheURI);
                if (!file.exists() && deleteCacheOnExit) {
                    file.deleteOnExit();
                }

                FileOutputStream out = new FileOutputStream(file);
                try {
                    pVariations.store(out, pCacheURI + " Vary info");
                }
                finally {
                    out.close();
                }
            }
            catch (IOException ioe) {
                log("Error: Could not store Vary info: " + ioe);
            }
        }
    }

    private Properties getVaryProperties(final String pCacheURI) {
        Properties variations;

        synchronized (varyCache) {
            variations = varyCache.get(pCacheURI);
            if (variations == null) {
                variations = loadVaryProperties(pCacheURI);
                varyCache.put(pCacheURI, variations);
            }
        }

        return variations;
    }

    private Properties loadVaryProperties(final String pCacheURI) {
        // Read Vary info, for content negotiation
        Properties variations = new Properties();
        File vary = getVaryPropertiesFile(pCacheURI);
        if (vary.exists()) {
            try {
                FileInputStream in = new FileInputStream(vary);
                try {
                    variations.load(in);
                }
                finally {
                    in.close();
                }
            }
            catch (IOException ioe) {
                log("Error: Could not load Vary info: " + ioe);
            }
        }
        return variations;
    }

    private File getVaryPropertiesFile(final String pCacheURI) {
        return new File(tempDir, "./" + pCacheURI + FILE_EXT_VARY);
    }

    private static String generateCacheURI(final CacheRequest pRequest) {
        StringBuilder buffer = new StringBuilder();

        // Note: As the '/'s are not replaced, the directory structure will be recreated
        // TODO: Old mehtod relied on context relativization, that must now be handled byt the ServletCacheRequest
//        String contextRelativeURI = ServletUtil.getContextRelativeURI(pRequest);
        String contextRelativeURI = pRequest.getRequestURI().getPath();
        buffer.append(contextRelativeURI);

        // Create directory for all resources
        if (contextRelativeURI.charAt(contextRelativeURI.length() - 1) != '/') {
            buffer.append('/');
        }

        // Get parameters from request, and recreate query to avoid unneccessary
        // regeneration/caching when parameters are out of order
        // Also makes caching work for POST
        appendSortedRequestParams(pRequest, buffer);

        return buffer.toString();
    }

    private static void appendSortedRequestParams(final CacheRequest pRequest, final StringBuilder pBuffer) {
        Set<String> names = pRequest.getParameters().keySet();
        if (names.isEmpty()) {
            pBuffer.append("defaultVersion");
            return;
        }

        // We now have parameters
        pBuffer.append('_'); // append '_' for '?', to avoid clash with default

        // Create a sorted map
        SortedMap<String, List<String>> sortedQueryMap = new TreeMap<String, List<String>>();
        for (String name : names) {
            List<String> values = pRequest.getParameters().get(name);

            sortedQueryMap.put(name, values);
        }

        // Iterate over sorted map, and append to stringbuffer
        for (Iterator<Map.Entry<String,List<String>>> iterator = sortedQueryMap.entrySet().iterator(); iterator.hasNext();) {
            Map.Entry<String,List<String>> entry = iterator.next();
            pBuffer.append(createSafe(entry.getKey()));

            List<String> values = entry.getValue();
            if (values != null && values.size() > 0) {
                pBuffer.append("_V"); // =
                for (int i = 0; i < values.size(); i++) {
                    String value = values.get(i);
                    if (i != 0) {
                        pBuffer.append(',');
                    }
                    pBuffer.append(createSafe(value));
                }
            }

            if (iterator.hasNext()) {
                pBuffer.append("_P"); // &
            }
        }
    }

    private static String createSafe(final String pKey) {
        return pKey.replace('/', '-')
                .replace('&', '-') // In case they are encoded
                .replace('#', '-')
                .replace(';', '-');
    }

    private static String createSafeHeader(final String pHeaderValue) {
        if (pHeaderValue == null) {
            return "NULL";
        }

        return pHeaderValue.replace(' ', '_')
                .replace(':', '_')
                .replace('=', '_');
    }

    /**
     * Registers content for the given URI in the cache.
     *
     * @param pCacheURI       the cache URI
     * @param pRequest        the request
     * @param pCachedResponse the cached response
     * @throws IOException if the content could not be cached
     */
    void registerContent(
            final String pCacheURI,
            final CacheRequest pRequest,
            final CachedResponse pCachedResponse
    ) throws IOException {
        // System.out.println(" ## HTTPCache ## Registering content for " + pCacheURI);

//        pRequest.removeAttribute(ATTRIB_IS_STALE);
//        pRequest.setAttribute(ATTRIB_CACHED_RESPONSE, pCachedResponse);

        if ("HEAD".equals(pRequest.getMethod())) {
            // System.out.println(" ## HTTPCache ## Was HEAD request, will NOT store content.");
            return;
        }

        // TODO: Several resources may have same extension...
        String extension = MIMEUtil.getExtension(pCachedResponse.getHeaderValue(HEADER_CONTENT_TYPE));
        if (extension == null) {
            extension = "[NULL]";
        }

        synchronized (contentCache) {
            contentCache.put(pCacheURI + '.' + extension, pCachedResponse);

            // This will be the default version
            if (!contentCache.containsKey(pCacheURI)) {
                contentCache.put(pCacheURI, pCachedResponse);
            }
        }

        // Write the cached content to disk
        File content = new File(tempDir, "./" + pCacheURI + '.' + extension);
        if (deleteCacheOnExit && !content.exists()) {
            content.deleteOnExit();
        }

        File parent = content.getParentFile();
        if (!(parent.exists() || parent.mkdirs())) {
            log("Could not create directory " + parent.getAbsolutePath());

            // TODO: Make sure vary-info is still created in memory

            return;
        }

        OutputStream mContentStream = new BufferedOutputStream(new FileOutputStream(content));

        try {
            pCachedResponse.writeContentsTo(mContentStream);
        }
        finally {
            try {
                mContentStream.close();
            }
            catch (IOException e) {
                log("Error closing content stream: " + e.getMessage(), e);
            }
        }

        // Write the cached headers to disk (in pseudo-properties-format)
        File headers = new File(content.getAbsolutePath() + FILE_EXT_HEADERS);
        if (deleteCacheOnExit && !headers.exists()) {
            headers.deleteOnExit();
        }

        FileWriter writer = new FileWriter(headers);
        PrintWriter headerWriter = new PrintWriter(writer);
        try {
            String[] names = pCachedResponse.getHeaderNames();

            for (String name : names) {
                String[] values = pCachedResponse.getHeaderValues(name);

                headerWriter.print(name);
                headerWriter.print(": ");
                headerWriter.println(StringUtil.toCSVString(values, "\\"));
            }
        }
        finally {
            headerWriter.flush();
            try {
                writer.close();
            }
            catch (IOException e) {
                log("Error closing header stream: " + e.getMessage(), e);
            }
        }

        // TODO: Make this more robust, if some weird entity is not
        // consistent in it's vary-headers..
        // (sometimes Vary, sometimes not, or somtimes different Vary headers).

        // Write extra Vary info to disk
        String[] varyHeaders = pCachedResponse.getHeaderValues(HEADER_VARY);

        // If no variations, then don't store vary info
        if (varyHeaders != null && varyHeaders.length > 0) {
            Properties variations = getVaryProperties(pCacheURI);

            String vary = StringUtil.toCSVString(varyHeaders);
            variations.setProperty(HEADER_VARY, vary);

            // Create Vary-key and map to file extension...
            String varyKey = createVaryKey(varyHeaders, pRequest);
//            System.out.println("varyKey: " + varyKey);
//            System.out.println("extension: " + extension);
            variations.setProperty(varyKey, extension);

            storeVaryProperties(pCacheURI, variations);
        }
    }

    /**
     * @param pCacheURI the cache URI
     * @param pRequest  the request
     * @return a {@code CachedResponse} object
     */
    CachedResponse getContent(final String pCacheURI, final CacheRequest pRequest) {
//         System.err.println(" ## HTTPCache ## Looking up content for " + pCacheURI);
//        Thread.dumpStack();

        String extension = getVaryExtension(pCacheURI, pRequest);

        CachedResponse response;
        synchronized (contentCache) {
//             System.out.println(" ## HTTPCache ## Looking up content with ext: \"" + extension + "\" from memory cache (" + contentCache /*.size()*/ + " entries)...");
            if ("ANY".equals(extension)) {
                response = contentCache.get(pCacheURI);
            }
            else {
                response = contentCache.get(pCacheURI + '.' + extension);
            }

            if (response == null) {
//                 System.out.println(" ## HTTPCache ## Content not found in memory cache.");
//
//                 System.out.println(" ## HTTPCache ## Looking up content from disk cache...");
                // Read from disk-cache
                response = readFromDiskCache(pCacheURI, pRequest);
            }

//            if (response == null) {
//                System.out.println(" ## HTTPCache ## Content not found in disk cache.");
//            }
//            else {
//                System.out.println(" ## HTTPCache ## Content for " + pCacheURI + " found: " + response);
//            }
        }

        return response;
    }

    private CachedResponse readFromDiskCache(String pCacheURI, CacheRequest pRequest) {
        CachedResponse response = null;
        try {
            File content = getCachedFile(pCacheURI, pRequest);
            if (content != null && content.exists()) {
                // Read contents
                byte[] contents = FileUtil.read(content);

                // Read headers
                File headers = new File(content.getAbsolutePath() + FILE_EXT_HEADERS);
                int headerSize = (int) headers.length();

                BufferedReader reader = new BufferedReader(new FileReader(headers));
                LinkedHashMap<String, List<String>> headerMap = new LinkedHashMap<String, List<String>>();
                String line;
                while ((line = reader.readLine()) != null) {
                    int colIdx = line.indexOf(':');
                    String name;
                    String value;
                    if (colIdx >= 0) {
                        name = line.substring(0, colIdx);
                        value = line.substring(colIdx + 2); // ": "
                    }
                    else {
                        name = line;
                        value = "";
                    }

                    headerMap.put(name, Arrays.asList(StringUtil.toStringArray(value, "\\")));
                }

                response = new CachedResponseImpl(HttpServletResponse.SC_OK, headerMap, headerSize, contents);
                contentCache.put(pCacheURI + '.' + FileUtil.getExtension(content), response);
            }
        }
        catch (IOException e) {
            log("Error reading from cache: " + e.getMessage(), e);
        }
        return response;
    }

    boolean isContentStale(final String pCacheURI, final CacheRequest pRequest) {
        // NOTE: Content is either stale or not, for the duration of one request, unless re-fetched
        // Means that we must retry after a registerContent(), if caching as request-attribute
        Boolean stale;
//        stale = (Boolean) pRequest.getAttribute(ATTRIB_IS_STALE);
//        if (stale != null) {
//            return stale;
//        }

        stale = isContentStaleImpl(pCacheURI, pRequest);
//        pRequest.setAttribute(ATTRIB_IS_STALE, stale);

        return stale;
    }

    private boolean isContentStaleImpl(final String pCacheURI, final CacheRequest pRequest) {
        CachedResponse response = getContent(pCacheURI, pRequest);

        if (response == null) {
            // System.out.println(" ## HTTPCache ## Content is stale (no content).");
            return true;
        }

        // TODO: Get max-age=... from REQUEST too!

        // TODO: What about time skew? Now should be (roughly) same as:
        // long now = pRequest.getDateHeader("Date");
        // TODO: If the time differs (server "now" vs client "now"), should we
        // take that into consideration when testing for stale content?
        // Probably, yes.
        // TODO: Define rules for how to handle time skews

        // Set timestamp check
        // NOTE: HTTP Dates are always in GMT time zone
        long now = (System.currentTimeMillis() / 1000L) * 1000L;
        long expires = getDateHeader(response.getHeaderValue(HEADER_EXPIRES));
        //long lastModified = getDateHeader(response, HEADER_LAST_MODIFIED);
        long lastModified = getDateHeader(response.getHeaderValue(HEADER_CACHED_TIME));

        // If expires header is not set, compute it
        if (expires == -1L) {
            /*
            // Note: Not all content has Last-Modified header. We should then
            // use lastModified() of the cached file, to compute expires time.
            if (lastModified == -1L) {
                File cached = getCachedFile(pCacheURI, pRequest);
                if (cached != null && cached.exists()) {
                    lastModified = cached.lastModified();
                    //// System.out.println(" ## HTTPCache ## Last-Modified is " + HTTPUtil.formatHTTPDate(lastModified) + ", using cachedFile.lastModified()");
                }
            }
            */

            // If Cache-Control: max-age is present, use it, otherwise default
            int maxAge = getIntHeader(response, HEADER_CACHE_CONTROL, "max-age");
            if (maxAge == -1) {
                expires = lastModified + defaultExpiryTime;
                //// System.out.println(" ## HTTPCache ## Expires is " + HTTPUtil.formatHTTPDate(expires) + ", using lastModified + defaultExpiry");
            }
            else {
                expires = lastModified + (maxAge * 1000L); // max-age is seconds
                //// System.out.println(" ## HTTPCache ## Expires is " + HTTPUtil.formatHTTPDate(expires) + ", using lastModified + maxAge");
            }
        }
        /*
        else {
            // System.out.println(" ## HTTPCache ## Expires header is " + response.getHeaderValue(HEADER_EXPIRES));
        }
        */

        // Expired?
        if (expires < now) {
            // System.out.println(" ## HTTPCache ## Content is stale (content expired: "
            //        + HTTPUtil.formatHTTPDate(expires) + " before " + HTTPUtil.formatHTTPDate(now) + ").");
            return true;
        }

        /*
        if (lastModified == -1L) {
            // Note: Not all content has Last-Modified header. We should then
            // use lastModified() of the cached file, to compute expires time.
            File cached = getCachedFile(pCacheURI, pRequest);
            if (cached != null && cached.exists()) {
                lastModified = cached.lastModified();
                //// System.out.println(" ## HTTPCache ## Last-Modified is " + HTTPUtil.formatHTTPDate(lastModified) + ", using cachedFile.lastModified()");
            }
        }
        */

        // Get the real file for this request, if any
        File real = getRealFile(pRequest);
        //noinspection RedundantIfStatement
        if (real != null && real.exists() && real.lastModified() > lastModified) {
            // System.out.println(" ## HTTPCache ## Content is stale (new content"
            //        + HTTPUtil.formatHTTPDate(lastModified) + " before " + HTTPUtil.formatHTTPDate(real.lastModified()) + ").");
            return true;
        }

        return false;
    }

    /**
     * Parses a cached header with directive to an int.
     * E.g: Cache-Control: max-age=60, returns 60
     *
     * @param pCached     the cached response
     * @param pHeaderName the header name (e.g: {@code CacheControl})
     * @param pDirective  the directive (e.g: {@code max-age}
     * @return the int value, or {@code -1} if not found
     */
    private int getIntHeader(final CachedResponse pCached, final String pHeaderName, final String pDirective) {
        String[] headerValues = pCached.getHeaderValues(pHeaderName);
        int value = -1;

        if (headerValues != null) {
            for (String headerValue : headerValues) {
                if (pDirective == null) {
                    if (!StringUtil.isEmpty(headerValue)) {
                        value = Integer.parseInt(headerValue);
                    }
                    break;
                }
                else {
                    int start = headerValue.indexOf(pDirective);

                    // Directive found
                    if (start >= 0) {

                        int end = headerValue.lastIndexOf(',');
                        if (end < start) {
                            end = headerValue.length();
                        }

                        headerValue = headerValue.substring(start, end);

                        if (!StringUtil.isEmpty(headerValue)) {
                            value = Integer.parseInt(headerValue);
                        }

                        break;
                    }
                }
            }
        }

        return value;
    }

    /**
     * Utility to read a date header from a cached response.
     *
     * @param pHeaderValue the header value
     * @return the parsed date as a long, or {@code -1L} if not found
     * @see javax.servlet.http.HttpServletRequest#getDateHeader(String)
     */
    static long getDateHeader(final String pHeaderValue) {
        long date = -1L;
        if (pHeaderValue != null) {
            date = HTTPUtil.parseHTTPDate(pHeaderValue);
        }
        return date;
    }

    // TODO: Extract and make public?
    final static class SizedLRUMap<K, V> extends LRUHashMap<K, V> {
        int currentSize;
        int maxSize;

        public SizedLRUMap(int pMaxSize) {
            //super(true);
            super(); // Note: super.maxSize doesn't count...
            maxSize = pMaxSize;
        }


        // In super (LRUMap?) this could just return 1...
        protected int sizeOf(Object pValue) {
            // HACK: As this is used as a backing for a TimeoutMap, the values
            // will themselves be Entries...
            while (pValue instanceof Map.Entry) {
                pValue = ((Map.Entry) pValue).getValue();
            }

            CachedResponse cached = (CachedResponse) pValue;
            return (cached != null ? cached.size() : 0);
        }

        @Override
        public V put(K pKey, V pValue) {
            currentSize += sizeOf(pValue);

            V old = super.put(pKey, pValue);
            if (old != null) {
                currentSize -= sizeOf(old);
            }
            return old;
        }

        @Override
        public V remove(Object pKey) {
            V old = super.remove(pKey);
            if (old != null) {
                currentSize -= sizeOf(old);
            }
            return old;
        }

        @Override
        protected boolean removeEldestEntry(Map.Entry<K, V> pEldest) {
            if (maxSize <= currentSize) { // NOTE: maxSize here is mem size
                removeLRU();
            }
            return false;
        }

        @Override
        public void removeLRU() {
            while (maxSize <= currentSize) { // NOTE: maxSize here is mem size
                super.removeLRU();
            }
        }
    }

}