//
// $Id$
//
// Nenya library - tools for developing networked games
// Copyright (C) 2002-2010 Three Rings Design, Inc., All Rights Reserved
// http://www.threerings.net/code/nenya/
//
// This library is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as published
// by the Free Software Foundation; either version 2.1 of the License, or
// (at your option) any later version.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA

package com.threerings.media.image;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;

import java.io.FileNotFoundException;
import java.io.IOException;

import java.awt.Component;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.Transparency;
import java.awt.image.BufferedImage;

import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;

import com.samskivert.util.LRUHashMap;
import com.samskivert.util.StringUtil;
import com.samskivert.util.Throttle;
import com.samskivert.util.Tuple;

import com.threerings.resource.ResourceManager;

import static com.threerings.media.Log.log;

/**
 * Provides a single point of access for image retrieval and caching.  This does not include
 * any tie-in to runtime adjustments to control caching and mirage creation.
 */
public class ImageManager
    implements ImageUtil.ImageCreator
{
    /**
     * Used to identify an image for caching and reconstruction.
     */
    public static class ImageKey
    {
        /** The data provider from which this image's data is loaded. */
        public ImageDataProvider daprov;

        /** The path used to identify the image to the data provider. */
        public String path;

        protected ImageKey (ImageDataProvider daprov, String path)
        {
            this.daprov = daprov;
            this.path = path;
        }

        @Override
        public int hashCode ()
        {
            return path.hashCode() ^ daprov.getIdent().hashCode();
        }

        @Override
        public boolean equals (Object other)
        {
            if (other == null || !(other instanceof ImageKey)) {
                return false;
            }

            ImageKey okey = (ImageKey)other;
            return ((okey.daprov.getIdent().equals(daprov.getIdent())) && (okey.path.equals(path)));
        }

        @Override
        public String toString ()
        {
            return daprov.getIdent() + ":" + path;
        }
    }

    /**
     * This interface allows the image manager to create images that are in a format optimal for
     * rendering to the screen.
     */
    public interface OptimalImageCreator
    {
        /**
         * Requests that a blank image be created that is in a format and of a depth that are
         * optimal for rendering to the screen.
         */
        public BufferedImage createImage (int width, int height, int trans);
    }

    /**
     * Construct an image manager with the specified {@link ResourceManager} from which it will
     * obtain its data.
     */
    public ImageManager (ResourceManager rmgr, OptimalImageCreator icreator)
    {
        _rmgr = rmgr;
        _icreator = icreator;

        // create our image cache
        int icsize = getCacheSize();
        log.debug("Creating image cache", "size", (icsize + "k"));
        _ccache = new LRUHashMap<ImageKey, CacheRecord>(
                icsize * 1024, new LRUHashMap.ItemSizer<CacheRecord>() {
            public int computeSize (CacheRecord value) {
                return (int)value.getEstimatedMemoryUsage();
            }
        });
        _ccache.setTracking(true);
    }

    /**
     * A convenience constructor that creates an {@link AWTImageCreator} for use by the image
     * manager.
     */
    public ImageManager (ResourceManager rmgr, Component context)
    {
        this(rmgr, new AWTImageCreator(context));
    }

    /**
     * Returns how much space we're willing to use for caching images.
     */
    public int getCacheSize ()
    {
        return DEFAULT_CACHE_SIZE;
    }

    /**
     * Clears all images out of the cache.
     */
    public void clearCache ()
    {
        log.info("Clearing image manager cache.");

        synchronized (_ccache) {
            _ccache.clear();
        }
    }

    /**
     * Creates a buffered image, optimized for display on our graphics device.
     */
    public BufferedImage createImage (int width, int height, int transparency)
    {
        return _icreator.createImage(width, height, transparency);
    }

    /**
     * Loads (and caches) the specified image from the resource manager using the supplied path to
     * identify the image.
     */
    public BufferedImage getImage (String path)
    {
        return getImage(null, path, null);
    }

    /**
     * Like {@link #getImage(String)} but the specified colorizations are applied to the image
     * before it is returned.
     */
    public BufferedImage getImage (String path, Colorization[] zations)
    {
        return getImage(null, path, zations);
    }

    /**
     * Like {@link #getImage(String)} but the image is loaded from the specified resource set
     * rathern than the default resource set.
     */
    public BufferedImage getImage (String rset, String path)
    {
        return getImage(rset, path, null);
    }

    /**
     * Like {@link #getImage(String,String)} but the specified colorizations are applied to the
     * image before it is returned.
     */
    public BufferedImage getImage (String rset, String path, Colorization[] zations)
    {
        if (StringUtil.isBlank(path)) {
            String errmsg = "Invalid image path [rset=" + rset + ", path=" + path + "]";
            throw new IllegalArgumentException(errmsg);
        }
        return getImage(getImageKey(rset, path), zations);
    }

    /**
     * Loads (and caches) the specified image from the resource manager using the supplied path to
     * identify the image.
     *
     * <p> Additionally the image is optimized for display in the current graphics
     * configuration. Consider using {@link #getMirage(ImageKey)} instead of prepared images as
     * they (some day) will automatically use volatile images to increase performance.
     */
    public BufferedImage getPreparedImage (String path)
    {
        return getPreparedImage(null, path, null);
    }

    /**
     * Loads (and caches) the specified image from the resource manager, obtaining the image from
     * the supplied resource set.
     *
     * <p> Additionally the image is optimized for display in the current graphics
     * configuration. Consider using {@link #getMirage(ImageKey)} instead of prepared images as
     * they (some day) will automatically use volatile images to increase performance.
     */
    public BufferedImage getPreparedImage (String rset, String path)
    {
        return getPreparedImage(rset, path, null);
    }

    /**
     * Loads (and caches) the specified image from the resource manager, obtaining the image from
     * the supplied resource set and applying the using the supplied path to identify the image.
     *
     * <p> Additionally the image is optimized for display in the current graphics
     * configuration. Consider using {@link #getMirage(ImageKey,Colorization[])} instead of
     * prepared images as they (some day) will automatically use volatile images to increase
     * performance.
     */
    public BufferedImage getPreparedImage (String rset, String path, Colorization[] zations)
    {
        BufferedImage image = getImage(rset, path, zations);
        BufferedImage prepped = null;
        if (image != null) {
            prepped = createImage(image.getWidth(), image.getHeight(),
                                  image.getColorModel().getTransparency());
            Graphics2D pg = prepped.createGraphics();
            pg.drawImage(image, 0, 0, null);
            pg.dispose();
        }
        return prepped;
    }

    /**
     * Returns an image key that can be used to fetch the image identified by the specified
     * resource set and image path.
     */
    public ImageKey getImageKey (String rset, String path)
    {
        return getImageKey(getDataProvider(rset), path);
    }

    /**
     * Returns an image key that can be used to fetch the image identified by the specified data
     * provider and image path.
     */
    public ImageKey getImageKey (ImageDataProvider daprov, String path)
    {
        return new ImageKey(daprov, path);
    }

    /**
     * Obtains the image identified by the specified key, caching if possible. The image will be
     * recolored using the supplied colorizations if requested.
     */
    public BufferedImage getImage (ImageKey key, Colorization[] zations)
    {
        CacheRecord crec = null;
        synchronized (_ccache) {
            crec = _ccache.get(key);
        }
        if (crec != null) {
//             log.info("Cache hit", "key", key, "crec", crec);
            return crec.getImage(zations, _ccache);
        }
//         log.info("Cache miss", "key", key, "crec", crec);

        // load up the raw image
        BufferedImage image = loadImage(key);
        if (image == null) {
            log.warning("Failed to load image " + key + ".");
            // create a blank image instead
            image = new BufferedImage(10, 10, BufferedImage.TYPE_BYTE_INDEXED);
        }

//         log.info("Loaded Image", "path", key.path, "image", image,
//                  "size", ImageUtil.getEstimatedMemoryUsage(image));

        // create a cache record
        crec = new CacheRecord(key, image);
        synchronized (_ccache) {
            _ccache.put(key, crec);
        }
        _keySet.add(key);

        // periodically report our image cache performance
        reportCachePerformance();

        return crec.getImage(zations, _ccache);
    }

    /**
     * Creates a mirage which is an image optimized for display on our current display device and
     * which will be stored into video memory if possible.
     */
    public Mirage getMirage (String rsrcPath)
    {
        return getMirage(getImageKey(_defaultProvider, rsrcPath), null, null);
    }

    /**
     * Creates a mirage which is an image optimized for display on our current display device and
     * which will be stored into video memory if possible.
     */
    public Mirage getMirage (ImageKey key)
    {
        return getMirage(key, null, null);
    }

    /**
     * Like {@link #getMirage(ImageKey)} but that only the specified subimage of the source image
     * is used to build the mirage.
     */
    public Mirage getMirage (ImageKey key, Rectangle bounds)
    {
        return getMirage(key, bounds, null);
    }

    /**
     * Like {@link #getMirage(ImageKey)} but the supplied colorizations are applied to the source
     * image before creating the mirage.
     */
    public Mirage getMirage (ImageKey key, Colorization[] zations)
    {
        return getMirage(key, null, zations);
    }

    /**
     * Like {@link #getMirage(ImageKey,Colorization[])} except that the mirage is created using
     * only the specified subset of the original image.
     */
    public Mirage getMirage (ImageKey key, Rectangle bounds, Colorization[] zations)
    {
        BufferedImage src = null;
        if (bounds == null) {
            // if they specified no bounds, we need to load up the raw image and determine its
            // bounds so that we can pass those along to the created mirage
            src = getImage(key, zations);
            bounds = new Rectangle(0, 0, src.getWidth(), src.getHeight());
        }
        return new CachedVolatileMirage(this, key, bounds, zations);

    }

    /**
     * Returns the image creator that can be used to create buffered images optimized for rendering
     * to the screen.
     */
    public OptimalImageCreator getImageCreator ()
    {
        return _icreator;
    }

    /**
     * Returns the data provider configured to obtain image data from the specified resource set.
     */
    protected ImageDataProvider getDataProvider (final String rset)
    {
        if (rset == null) {
            return _defaultProvider;
        }

        ImageDataProvider dprov = _providers.get(rset);
        if (dprov == null) {
            dprov = new ImageDataProvider() {
                public BufferedImage loadImage (String path)
                    throws IOException {
                    // first attempt to load the image from the specified resource set
                    try {
                        return _rmgr.getImageResource(rset, path);
                    } catch (FileNotFoundException fnfe) {
                        // fall back to trying the classpath
                        return _rmgr.getImageResource(path);
                    }
                }

                public String getIdent () {
                    return "rmgr:" + rset;
                }
            };
            _providers.put(rset, dprov);
        }

        return dprov;
    }

    /**
     * Loads and returns the image with the specified key from the supplied data provider.
     */
    protected BufferedImage loadImage (ImageKey key)
    {
//         if (EventQueue.isDispatchThread()) {
//             Log.info("Loading image on AWT thread " + key + ".");
//         }

        BufferedImage image = null;
        try {
            log.debug("Loading image " + key + ".");
            image = key.daprov.loadImage(key.path);
            if (image == null) {
                log.warning("ImageDataProvider.loadImage(" + key + ") returned null.");
            }

        } catch (Exception e) {
            log.warning("Unable to load image '" + key + "'.", e);

            // create a blank image in its stead
            image = createImage(1, 1, Transparency.OPAQUE);
        }

        return image;
    }

    /**
     * Reports statistics detailing the image manager cache performance and the current size of the
     * cached images.
     */
    protected void reportCachePerformance ()
    {
        if (/* Log.getLevel() != Log.log.DEBUG || */
            _cacheStatThrottle.throttleOp()) {
            return;
        }

        // compute our estimated memory usage
        long size = 0;

        int[] eff = null;
        synchronized (_ccache) {
            Iterator<CacheRecord> iter = _ccache.values().iterator();
            while (iter.hasNext()) {
                size += iter.next().getEstimatedMemoryUsage();
            }
            eff = _ccache.getTrackedEffectiveness();
        }
        log.info("ImageManager LRU", "mem", ((size / 1024) + "k"), "size", _ccache.size(),
            "hits", eff[0], "misses", eff[1], "totalKeys", _keySet.size());
    }

    /** Maintains a source image and a set of colorized versions in the image cache. */
    protected static class CacheRecord
    {
        public CacheRecord (ImageKey key, BufferedImage source)
        {
            _key = key;
            _source = source;
        }

        public BufferedImage getImage (Colorization[] zations, LRUHashMap<ImageKey, CacheRecord> cache)
        {
            if (zations == null) {
                return _source;
            }

            if (_colorized == null) {
                _colorized = Lists.newArrayList();
            }

            // we search linearly through our list of colorized copies because it is not likely to
            // be very long
            int csize = _colorized.size();
            for (int ii = 0; ii < csize; ii++) {
                Tuple<Colorization[], BufferedImage> tup = _colorized.get(ii);
                Colorization[] tzations = tup.left;
                if (Arrays.equals(zations, tzations)) {
                    return tup.right;
                }
            }

            try {
                BufferedImage cimage = ImageUtil.recolorImage(_source, zations);
                _colorized.add(new Tuple<Colorization[], BufferedImage>(zations, cimage));
                synchronized (cache) {
                    cache.adjustSize((int)ImageUtil.getEstimatedMemoryUsage(cimage));
                }
                return cimage;

            } catch (Exception re) {
                log.warning("Failure recoloring image",
                    "source", _key, "zations", StringUtil.toString(zations), "error", re);
                // return the uncolorized version
                return _source;
            }
        }

        public long getEstimatedMemoryUsage ()
        {
            long usage = ImageUtil.getEstimatedMemoryUsage(_source);
            if (_colorized != null) {
                for (Tuple<Colorization[], BufferedImage> tup : _colorized) {
                    usage += ImageUtil.getEstimatedMemoryUsage(tup.right);
                }
            }
            return usage;
        }

        @Override
        public String toString ()
        {
            return "[key=" + _key + ", wid=" + _source.getWidth() + ", hei=" + _source.getHeight() +
                ", ccount=" + ((_colorized == null) ? 0 : _colorized.size()) + "]";
        }

        protected ImageKey _key;
        protected BufferedImage _source;
        protected ArrayList<Tuple<Colorization[], BufferedImage>> _colorized;
    }

    /** A reference to the resource manager via which we load image data by default. */
    protected ResourceManager _rmgr;

    /** We use this to create images optimized for rendering. */
    protected OptimalImageCreator _icreator;

    /** A cache of loaded images. */
    protected LRUHashMap<ImageKey, CacheRecord> _ccache;

    /** The set of all keys we've ever seen. */
    protected HashSet<ImageKey> _keySet = Sets.newHashSet();

    /** Throttle our cache status logging to once every 300 seconds. */
    protected Throttle _cacheStatThrottle = new Throttle(1, 300000L);

    /** Our default data provider. */
    protected ImageDataProvider _defaultProvider = new ImageDataProvider() {
        public BufferedImage loadImage (String path) throws IOException {
            return _rmgr.getImageResource(path);
        }
        public String getIdent () {
            return "rmgr:default";
        }
    };

    /** Data providers for different resource sets. */
    protected Map<String, ImageDataProvider> _providers = Maps.newHashMap();

    /** Default amount of data we'll store in our image cache. */
    protected static int DEFAULT_CACHE_SIZE = 32768;
}
