/*
 * 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.
 */

/* $Id: FontCache.java 1761020 2016-09-16 11:17:35Z ssteiner $ */

package org.apache.fop.fonts;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.net.URLConnection;
import java.util.HashMap;
import java.util.Map;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.apache.fop.apps.FOPException;
import org.apache.fop.apps.io.InternalResourceResolver;
import org.apache.fop.util.LogUtil;

/**
 * Fop cache (currently only used for font info caching)
 */
public final class FontCache implements Serializable {

    /**
     * Serialization Version UID. Change this value if you want to make sure the
     * user's cache file is purged after an update.
     */
    private static final long serialVersionUID = 9129238336422194339L;

    /** logging instance */
    private static Log log = LogFactory.getLog(FontCache.class);

    /** FOP's user directory name */
    private static final String FOP_USER_DIR = ".fop";

    /** font cache file path */
    private static final String DEFAULT_CACHE_FILENAME = "fop-fonts.cache";

    /** has this cache been changed since it was last read? */
    private transient boolean changed;

    /** change lock */
    private final boolean[] changeLock = new boolean[1];

    /**
     * master mapping of font url -> font info. This needs to be a list, since a
     * TTC file may contain more than 1 font.
     * @serial
     */
    private Map<String, CachedFontFile> fontfileMap;

    /**
     * mapping of font url -> file modified date (for all fonts that have failed
     * to load)
     * @serial
     */
    private Map<String, Long> failedFontMap;

    private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException {
        ois.defaultReadObject();
    }

    private static File getUserHome() {
        return toDirectory(System.getProperty("user.home"));
    }

    private static File getTempDirectory() {
        return toDirectory(System.getProperty("java.io.tmpdir"));
    }

    private static File toDirectory(String path) {
        if (path != null) {
            File dir = new File(path);
            if (dir.exists()) {
                return dir;
            }
        }
        return null;
    }

    /**
     * Returns the default font cache file.
     *
     * @param forWriting
     *            true if the user directory should be created
     * @return the default font cache file
     */
    public static File getDefaultCacheFile(boolean forWriting) {
        File userHome = getUserHome();
        if (userHome != null) {
            File fopUserDir = new File(userHome, FOP_USER_DIR);
            if (forWriting) {
                boolean writable = fopUserDir.canWrite();
                if (!fopUserDir.exists()) {
                    writable = fopUserDir.mkdir();
                }
                if (!writable) {
                    userHome = getTempDirectory();
                    fopUserDir = new File(userHome, FOP_USER_DIR);
                    fopUserDir.mkdir();
                }
            }
            return new File(fopUserDir, DEFAULT_CACHE_FILENAME);
        }
        return new File(FOP_USER_DIR);
    }

    /**
     * Reads the default font cache file and returns its contents.
     *
     * @return the font cache deserialized from the file (or null if no cache
     *         file exists or if it could not be read)
     * @deprecated use {@link #loadFrom(File)} instead
     */
    public static FontCache load() {
        return loadFrom(getDefaultCacheFile(false));
    }

    /**
     * Reads a font cache file and returns its contents.
     *
     * @param cacheFile
     *            the cache file
     * @return the font cache deserialized from the file (or null if no cache
     *         file exists or if it could not be read)
     */
    public static FontCache loadFrom(File cacheFile) {
        if (cacheFile.exists()) {
            try {
                if (log.isTraceEnabled()) {
                    log.trace("Loading font cache from "
                            + cacheFile.getCanonicalPath());
                }
                InputStream in = new BufferedInputStream(new FileInputStream(cacheFile));
                ObjectInputStream oin = new ObjectInputStream(in);
                try {
                    return (FontCache) oin.readObject();
                } finally {
                    IOUtils.closeQuietly(oin);
                }
            } catch (ClassNotFoundException e) {
                // We don't really care about the exception since it's just a
                // cache file
                log.warn("Could not read font cache. Discarding font cache file. Reason: "
                        + e.getMessage());
            } catch (IOException ioe) {
                // We don't really care about the exception since it's just a
                // cache file
                log.warn("I/O exception while reading font cache ("
                        + ioe.getMessage() + "). Discarding font cache file.");
                try {
                    cacheFile.delete();
                } catch (SecurityException ex) {
                    log.warn("Failed to delete font cache file: "
                            + cacheFile.getAbsolutePath());
                }
            }
        }
        return null;
    }

    /**
     * Writes the font cache to disk.
     *
     * @throws FOPException fop exception
     * @deprecated use {@link #saveTo(File)} instead
     */
    public void save() throws FOPException {
        saveTo(getDefaultCacheFile(true));
    }

    /**
     * Writes the font cache to disk.
     *
     * @param cacheFile
     *            the file to write to
     * @throws FOPException
     *             fop exception
     */
    public void saveTo(File cacheFile) throws FOPException {
        synchronized (changeLock) {
            if (changed) {
                try {
                    log.trace("Writing font cache to " + cacheFile.getCanonicalPath());
                    OutputStream out = new java.io.FileOutputStream(cacheFile);
                    out = new java.io.BufferedOutputStream(out);
                    ObjectOutputStream oout = new ObjectOutputStream(out);
                    try {
                        oout.writeObject(this);
                    } finally {
                        IOUtils.closeQuietly(oout);
                    }
                } catch (IOException ioe) {
                    LogUtil.handleException(log, ioe, true);
                }
                changed = false;
                log.trace("Cache file written.");
            }
        }
    }

    /**
     * creates a key given a font info for the font mapping
     *
     * @param fontInfo
     *            font info
     * @return font cache key
     */
    protected static String getCacheKey(EmbedFontInfo fontInfo) {
        if (fontInfo != null) {
            URI embedFile = fontInfo.getEmbedURI();
            URI metricsFile = fontInfo.getMetricsURI();
            return (embedFile != null) ? embedFile.toASCIIString() : metricsFile.toASCIIString();
        }
        return null;
    }

    /**
     * cache has been updated since it was read
     *
     * @return if this cache has changed
     */
    public boolean hasChanged() {
        return this.changed;
    }

    /**
     * is this font in the cache?
     *
     * @param embedUrl
     *            font info
     * @return boolean
     */
    public boolean containsFont(String embedUrl) {
        return (embedUrl != null && getFontFileMap().containsKey(embedUrl));
    }

    /**
     * is this font info in the cache?
     *
     * @param fontInfo
     *            font info
     * @return font
     */
    public boolean containsFont(EmbedFontInfo fontInfo) {
        return (fontInfo != null && getFontFileMap().containsKey(
                getCacheKey(fontInfo)));
    }

    /**
     * Tries to identify a File instance from an array of URLs. If there's no
     * file URL in the array, the method returns null.
     *
     * @param urls
     *            array of possible font urls
     * @return file font file
     */
    public static File getFileFromUrls(String[] urls) {
        for (String urlStr : urls) {
            if (urlStr != null) {
                File fontFile = null;
                if (urlStr.startsWith("file:")) {
                    try {
                        URL url = new URL(urlStr);
                        fontFile = FileUtils.toFile(url);
                    } catch (MalformedURLException mfue) {
                        // do nothing
                    }
                }
                if (fontFile == null) {
                    fontFile = new File(urlStr);
                }
                if (fontFile.exists() && fontFile.canRead()) {
                    return fontFile;
                }
            }
        }
        return null;
    }

    private Map<String, CachedFontFile> getFontFileMap() {
        if (fontfileMap == null) {
            fontfileMap = new HashMap<String, CachedFontFile>();
        }
        return fontfileMap;
    }

    /**
     * Adds a font info to cache
     *
     * @param fontInfo
     *            font info
     */
    public void addFont(EmbedFontInfo fontInfo, InternalResourceResolver resourceResolver) {
        String cacheKey = getCacheKey(fontInfo);
        synchronized (changeLock) {
            CachedFontFile cachedFontFile;
            if (containsFont(cacheKey)) {
                cachedFontFile = getFontFileMap().get(cacheKey);
                if (!cachedFontFile.containsFont(fontInfo)) {
                    cachedFontFile.put(fontInfo);
                }
            } else {
                // try and determine modified date
                URI fontUri = resourceResolver.resolveFromBase(fontInfo.getEmbedURI());
                long lastModified = getLastModified(fontUri);
                cachedFontFile = new CachedFontFile(lastModified);
                if (log.isTraceEnabled()) {
                    log.trace("Font added to cache: " + cacheKey);
                }
                cachedFontFile.put(fontInfo);
                getFontFileMap().put(cacheKey, cachedFontFile);
                changed = true;
            }
        }
    }

    /**
     * Returns a font from the cache.
     *
     * @param embedUrl
     *            font info
     * @return CachedFontFile object
     */
    public CachedFontFile getFontFile(String embedUrl) {
        return containsFont(embedUrl) ? getFontFileMap().get(embedUrl) : null;
    }

    /**
     * Returns the EmbedFontInfo instances belonging to a font file. If the font
     * file was modified since it was cached the entry is removed and null is
     * returned.
     *
     * @param embedUrl
     *            the font URL
     * @param lastModified
     *            the last modified date/time of the font file
     * @return the EmbedFontInfo instances or null if there's no cached entry or
     *         if it is outdated
     */
    public EmbedFontInfo[] getFontInfos(String embedUrl, long lastModified) {
        CachedFontFile cff = getFontFile(embedUrl);
        if (cff.lastModified() == lastModified) {
            return cff.getEmbedFontInfos();
        } else {
            removeFont(embedUrl);
            return null;
        }
    }

    /**
     * removes font from cache
     *
     * @param embedUrl
     *            embed url
     */
    public void removeFont(String embedUrl) {
        synchronized (changeLock) {
            if (containsFont(embedUrl)) {
                if (log.isTraceEnabled()) {
                    log.trace("Font removed from cache: " + embedUrl);
                }
                getFontFileMap().remove(embedUrl);
                changed = true;
            }
        }
    }

    /**
     * has this font previously failed to load?
     *
     * @param embedUrl
     *            embed url
     * @param lastModified
     *            last modified
     * @return whether this is a failed font
     */
    public boolean isFailedFont(String embedUrl, long lastModified) {
        synchronized (changeLock) {
            if (getFailedFontMap().containsKey(embedUrl)) {
                long failedLastModified = getFailedFontMap().get(
                        embedUrl);
                if (lastModified != failedLastModified) {
                    // this font has been changed so lets remove it
                    // from failed font map for now
                    getFailedFontMap().remove(embedUrl);
                    changed = true;
                }
                return true;
            } else {
                return false;
            }
        }
    }

    /**
     * Registers a failed font with the cache
     *
     * @param embedUrl
     *            embed url
     * @param lastModified
     *            time last modified
     */
    public void registerFailedFont(String embedUrl, long lastModified) {
        synchronized (changeLock) {
            if (!getFailedFontMap().containsKey(embedUrl)) {
                getFailedFontMap().put(embedUrl, lastModified);
                changed = true;
            }
        }
    }

    private Map<String, Long> getFailedFontMap() {
        if (failedFontMap == null) {
            failedFontMap = new HashMap<String, Long>();
        }
        return failedFontMap;
    }

    /**
     * Clears font cache
     */
    public void clear() {
        synchronized (changeLock) {
            if (log.isTraceEnabled()) {
                log.trace("Font cache cleared.");
            }
            fontfileMap = null;
            failedFontMap = null;
            changed = true;
        }
    }

    /**
     * Retrieve the last modified date/time of a URI.
     *
     * @param uri the URI
     * @return the last modified date/time
     */
    public static long getLastModified(URI uri) {
        try {
            URL url = uri.toURL();
            URLConnection conn = url.openConnection();
            try {
                return conn.getLastModified();
            } finally {
                // An InputStream is created even if it's not accessed, but we
                // need to close it.
                IOUtils.closeQuietly(conn.getInputStream());
            }
        } catch (IOException e) {
            // Should never happen, because URL must be local
            log.debug("IOError: " + e.getMessage());
            return 0;
        }
    }

    private static class CachedFontFile implements Serializable {
        private static final long serialVersionUID = 4524237324330578883L;

        /** file modify date (if available) */
        private long lastModified = -1;

        private Map<String, EmbedFontInfo> filefontsMap;

        public CachedFontFile(long lastModified) {
            setLastModified(lastModified);
        }

        private Map<String, EmbedFontInfo> getFileFontsMap() {
            if (filefontsMap == null) {
                filefontsMap = new HashMap<String, EmbedFontInfo>();
            }
            return filefontsMap;
        }

        void put(EmbedFontInfo efi) {
            getFileFontsMap().put(efi.getPostScriptName(), efi);
        }

        public boolean containsFont(EmbedFontInfo efi) {
            return efi.getPostScriptName() != null
                    && getFileFontsMap().containsKey(efi.getPostScriptName());
        }

        public EmbedFontInfo[] getEmbedFontInfos() {
            return getFileFontsMap().values().toArray(
                    new EmbedFontInfo[getFileFontsMap().size()]);
        }

        /**
         * Gets the modified timestamp for font file (not always available)
         *
         * @return modified timestamp
         */
        public long lastModified() {
            return this.lastModified;
        }

        /**
         * Gets the modified timestamp for font file (used for the purposes of
         * font info caching)
         *
         * @param lastModified
         *            modified font file timestamp
         */
        public void setLastModified(long lastModified) {
            this.lastModified = lastModified;
        }

        /**
         * @return string representation of this object {@inheritDoc}
         */
        public String toString() {
            return super.toString() + ", lastModified=" + lastModified;
        }

    }
}
