//
// $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.Arrays;
import java.util.Iterator;

import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.awt.HeadlessException;
import java.awt.Rectangle;
import java.awt.Shape;
import java.awt.Transparency;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.awt.image.DataBuffer;
import java.awt.image.IndexColorModel;
import java.awt.image.Raster;
import java.awt.image.WritableRaster;

import com.samskivert.util.Logger;

import com.samskivert.swing.Label;

/**
 * Image related utility functions.
 */
public class ImageUtil
{
    public static interface ImageCreator
    {
        /** Used by routines that need to create new images to allow the caller to dictate the
         * format (which may mean using createCompatibleImage). */
        public BufferedImage createImage (int width, int height, int transparency);
    }

    /**
     * Creates a new buffered image with the same sample model and color model as the source image
     * but with the new width and height. */
    public static BufferedImage createCompatibleImage (BufferedImage source, int width, int height)
    {
        WritableRaster raster = source.getRaster().createCompatibleWritableRaster(width, height);
        return new BufferedImage(source.getColorModel(), raster, false, null);
    }

    /**
     * Creates an image with the word "Error" written in it.
     */
    public static BufferedImage createErrorImage (int width, int height)
    {
        BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_INDEXED);
        Graphics2D g = (Graphics2D)img.getGraphics();
        g.setColor(Color.red);
        Label l = new Label("Error");
        l.layout(g);
        Dimension d = l.getSize();
        // fill that sucker with errors
        for (int yy = 0; yy < height; yy += d.height) {
            for (int xx = 0; xx < width; xx += (d.width+5)) {
                l.render(g, xx, yy);
            }
        }
        g.dispose();
        return img;
    }

    /**
     * Used to recolor images by shifting bands of color (in HSV color space) to a new hue. The
     * source images must be 8-bit color mapped images, as the recoloring process works by
     * analysing the color map and modifying it.
     */
    public static BufferedImage recolorImage (
        BufferedImage image, Color rootColor, float[] dists, float[] offsets)
    {
        return recolorImage(image, new Colorization[] {
            new Colorization(-1, rootColor, dists, offsets) });
    }

    /**
     * Recolors the supplied image as in
     * {@link #recolorImage(BufferedImage,Color,float[],float[])} obtaining the recoloring
     * parameters from the supplied {@link Colorization} instance.
     */
    public static BufferedImage recolorImage (BufferedImage image, Colorization cz)
    {
        return recolorImage(image, new Colorization[] { cz });
    }

    /**
     * Recolors the supplied image using the supplied colorizations.
     */
    public static BufferedImage recolorImage (BufferedImage image, Colorization[] zations)
    {
        ColorModel cm = image.getColorModel();
        if (!(cm instanceof IndexColorModel)) {
            throw new RuntimeException(Logger.format(
                "Unable to recolor images with non-index color model", "cm", cm.getClass()));
        }

        // now process the image
        IndexColorModel icm = (IndexColorModel)cm;
        int size = icm.getMapSize();
        int zcount = zations.length;
        int[] rgbs = new int[size];

        // fetch the color data
        icm.getRGBs(rgbs);

        // convert the colors to HSV
        float[] hsv = new float[3];
        int[] fhsv = new int[3];
        for (int i = 0; i < size; i++) {
            int value = rgbs[i];

            // don't fiddle with alpha pixels
            if ((value & 0xFF000000) == 0) {
                continue;
            }

            // convert the color to HSV
            int red = (value >> 16) & 0xFF;
            int green = (value >> 8) & 0xFF;
            int blue = (value >> 0) & 0xFF;
            Color.RGBtoHSB(red, green, blue, hsv);
            Colorization.toFixedHSV(hsv, fhsv);

            // see if this color matches and of our colorizations and
            // recolor it if it does
            for (int z = 0; z < zcount; z++) {
                Colorization cz = zations[z];
                if (cz != null && cz.matches(hsv, fhsv)) {
                    // massage the HSV bands and update the RGBs array
                    rgbs[i] = cz.recolorColor(hsv);
                    break;
                }
            }
        }

        // create a new image with the adjusted color palette
        IndexColorModel nicm = new IndexColorModel(
            icm.getPixelSize(), size, rgbs, 0, icm.hasAlpha(),
            icm.getTransparentPixel(), icm.getTransferType());
        return new BufferedImage(nicm, image.getRaster(), false, null);
    }

    /**
     * Paints multiple copies of the supplied image using the supplied graphics context such that
     * the requested area is filled with the image.
     */
    public static void tileImage (
        Graphics2D gfx, Mirage image, int x, int y, int width, int height)
    {
        int iwidth = image.getWidth(), iheight = image.getHeight();
        int xnum = width / iwidth, xplus = width % iwidth;
        int ynum = height / iheight, yplus = height % iheight;
        Shape oclip = gfx.getClip();

        for (int ii=0; ii < ynum; ii++) {
            // draw the full copies of the image across
            int xx = x;
            for (int jj=0; jj < xnum; jj++) {
                image.paint(gfx, xx, y);
                xx += iwidth;
            }

            if (xplus > 0) {
                gfx.clipRect(xx, y, xplus, iheight);
                image.paint(gfx, xx, y);
                gfx.setClip(oclip);
            }

            y += iheight;
        }

        if (yplus > 0) {
            int xx = x;
            for (int jj=0; jj < xnum; jj++) {
                gfx.clipRect(xx, y, iwidth, yplus);
                image.paint(gfx, xx, y);
                gfx.setClip(oclip);
                xx += iwidth;
            }

            if (xplus > 0) {
                gfx.clipRect(xx, y, xplus, yplus);
                image.paint(gfx, xx, y);
                gfx.setClip(oclip);
            }
        }
    }

    /**
     * Paints multiple copies of the supplied image using the supplied graphics context such that
     * the requested width is filled with the image.
     */
    public static void tileImageAcross (Graphics2D gfx, Mirage image, int x, int y, int width)
    {
        tileImage(gfx, image, x, y, width, image.getHeight());
    }

    /**
     * Paints multiple copies of the supplied image using the supplied graphics context such that
     * the requested height is filled with the image.
     */
    public static void tileImageDown (Graphics2D gfx, Mirage image, int x, int y, int height)
    {
        tileImage(gfx, image, x, y, image.getWidth(), height);
    }

    // Not fully added because we're not using it anywhere, plus it's probably a little sketchy
    // to create Area objects with all this pixely data.
    // Also, the Area was getting zeroed out when it was translated. Something to look into someday
    // if anyone wants to use this method.
//    /**
//     * Creates a mask that is opaque in the non-transparent areas of the source image.
//     */
//    public static Area createImageMask (BufferedImage src)
//    {
//        Raster srcdata = src.getData();
//        int wid = src.getWidth(), hei = src.getHeight();
//        Log.info("creating area of (" + wid + ", " + hei + ")");
//        Area a = new Area(new Rectangle(wid, hei));
//        Rectangle r = new Rectangle(1, 1);
//
//        for (int yy=0; yy < hei; yy++) {
//            for (int xx=0; xx < wid; xx++) {
//                if (srcdata.getSample(xx, yy, 0) == 0) {
//                    r.setLocation(xx, yy);
//                    a.subtract(new Area(r));
//                }
//            }
//        }
//
//        return a;
//    }

    /**
     * Creates and returns a new image consisting of the supplied image traced with the given
     * color and thickness.
     */
    public static BufferedImage createTracedImage (
        ImageCreator isrc, BufferedImage src, Color tcolor, int thickness)
    {
        return createTracedImage(isrc, src, tcolor, thickness, 1.0f, 1.0f);
    }

    /**
     * Creates and returns a new image consisting of the supplied image traced with the given
     * color, thickness and alpha transparency.
     */
    public static BufferedImage createTracedImage (
        ImageCreator isrc, BufferedImage src, Color tcolor, int thickness,
        float startAlpha, float endAlpha)
    {
        // create the destination image
        int wid = src.getWidth(), hei = src.getHeight();
        BufferedImage dest = isrc.createImage(wid, hei, Transparency.TRANSLUCENT);
        return createTracedImage(src, dest, tcolor, thickness, startAlpha, endAlpha);
    }

    /**
     * Creates and returns a new image consisting of the supplied image traced with the given
     * color, thickness and alpha transparency.
     */
    public static BufferedImage createTracedImage (
        BufferedImage src, BufferedImage dest, Color tcolor, int thickness,
        float startAlpha, float endAlpha)
    {
        // prepare various bits of working data
        int wid = src.getWidth(), hei = src.getHeight();
        int spixel = (tcolor.getRGB() & RGB_MASK);
        int salpha = (int)(startAlpha * 255);
        int tpixel = (spixel | (salpha << 24));
        boolean[] traced = new boolean[wid * hei];
        int stepAlpha = (thickness <= 1) ? 0 :
            (int)(((startAlpha - endAlpha) * 255) / (thickness - 1));

        // TODO: this could be made more efficient, e.g., if we made four passes through the image
        // in a vertical scan, horizontal scan, and opposing diagonal scans, making sure each
        // non-transparent pixel found during each scan is traced on both sides of the respective
        // scan direction. For now, we just naively check all eight pixels surrounding each pixel
        // in the image and fill the center pixel with the tracing color if it's transparent but
        // has a non-transparent pixel around it.
        for (int tt = 0; tt < thickness; tt++) {
            if (tt > 0) {
                // clear out the array of pixels traced this go-around
                Arrays.fill(traced, false);
                // use the destination image as our new source
                src = dest;
                // decrement the trace pixel alpha-level
                salpha -= Math.max(0, stepAlpha);
                tpixel = (spixel | (salpha << 24));
            }

            for (int yy = 0; yy < hei; yy++) {
                for (int xx = 0; xx < wid; xx++) {
                    // get the pixel we're checking
                    int argb = src.getRGB(xx, yy);

                    if ((argb & TRANS_MASK) != 0) {
                        // copy any pixel that isn't transparent
                        dest.setRGB(xx, yy, argb);

                    } else if (bordersNonTransparentPixel(src, wid, hei, traced, xx, yy)) {
                        dest.setRGB(xx, yy, tpixel);
                        // note that we traced this pixel this pass so
                        // that it doesn't impact other-pixel borderedness
                        traced[(yy*wid)+xx] = true;
                    }
                }
            }
        }

        return dest;
    }

    /**
     * Returns whether the given pixel is bordered by any non-transparent pixel.
     */
    protected static boolean bordersNonTransparentPixel (
        BufferedImage data, int wid, int hei, boolean[] traced, int x, int y)
    {
        // check the three-pixel row above the pixel
        if (y > 0) {
            for (int rxx = x - 1; rxx <= x + 1; rxx++) {
                if (rxx < 0 || rxx >= wid || traced[((y-1)*wid)+rxx]) {
                    continue;
                }

                if ((data.getRGB(rxx, y - 1) & TRANS_MASK) != 0) {
                    return true;
                }
            }
        }

        // check the pixel to the left
        if (x > 0 && !traced[(y*wid)+(x-1)]) {
            if ((data.getRGB(x - 1, y) & TRANS_MASK) != 0) {
                return true;
            }
        }

        // check the pixel to the right
        if (x < wid - 1 && !traced[(y*wid)+(x+1)]) {
            if ((data.getRGB(x + 1, y) & TRANS_MASK) != 0) {
                return true;
            }
        }

        // check the three-pixel row below the pixel
        if (y < hei - 1) {
            for (int rxx = x - 1; rxx <= x + 1; rxx++) {
                if (rxx < 0 || rxx >= wid || traced[((y+1)*wid)+rxx]) {
                    continue;
                }

                if ((data.getRGB(rxx, y + 1) & TRANS_MASK) != 0) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Create an image using the alpha channel from the first and the RGB values from the second.
     */
    public static BufferedImage composeMaskedImage (
        ImageCreator isrc, BufferedImage mask, BufferedImage base)
    {
        int wid = base.getWidth();
        int hei = base.getHeight();

        Raster maskdata = mask.getData();
        Raster basedata = base.getData();

        // create a new image using the rasters if possible
        if (maskdata.getNumBands() == 4 && basedata.getNumBands() >= 3) {
            WritableRaster target = basedata.createCompatibleWritableRaster(wid, hei);

            // copy the alpha from the mask image
            int[] adata = maskdata.getSamples(0, 0, wid, hei, 3, (int[]) null);
            target.setSamples(0, 0, wid, hei, 3, adata);

            // copy the RGB from the base image
            for (int ii=0; ii < 3; ii++) {
                int[] cdata = basedata.getSamples(0, 0, wid, hei, ii, (int[]) null);
                target.setSamples(0, 0, wid, hei, ii, cdata);
            }

            return new BufferedImage(mask.getColorModel(), target, true, null);

        } else {
            // otherwise composite them by rendering them with an alpha
            // rule
            BufferedImage target = isrc.createImage(wid, hei, Transparency.TRANSLUCENT);
            Graphics2D g2 = target.createGraphics();
            try {
                g2.drawImage(mask, 0, 0, null);
                g2.setComposite(AlphaComposite.SrcIn);
                g2.drawImage(base, 0, 0, null);
            } finally {
                g2.dispose();
            }
            return target;
        }
    }

    /**
     * Create a new image using the supplied shape as a mask from which to cut out pixels from the
     * supplied image. Pixels inside the shape will be added to the final image, pixels outside
     * the shape will be clear.
     */
    public static BufferedImage composeMaskedImage (
        ImageCreator isrc, Shape mask, BufferedImage base)
    {
        int wid = base.getWidth();
        int hei = base.getHeight();

        // alternate method for composition:
        // 1. create WriteableRaster with base data
        // 2. test each pixel with mask.contains() and set the alpha channel to fully-alpha if false
        // 3. create buffered image from raster
        // (I didn't use this method because it depends on the colormodel of the source image, and
        // was booching when the souce image was a cut-up from a tileset, and it seems like it
        // would take longer than the method we are using. But it's something to consider)

        // composite them by rendering them with an alpha rule
        BufferedImage target = isrc.createImage(wid, hei, Transparency.TRANSLUCENT);
        Graphics2D g2 = target.createGraphics();
        try {
            g2.setColor(Color.BLACK); // whatever, really
            g2.fill(mask);
            g2.setComposite(AlphaComposite.SrcIn);
            g2.drawImage(base, 0, 0, null);
        } finally {
            g2.dispose();
        }
        return target;
    }

    /**
     * Returns true if the supplied image contains a non-transparent pixel at the specified
     * coordinates, false otherwise.
     */
    public static boolean hitTest (BufferedImage image, int x, int y)
    {
        // it's only a hit if the pixel is non-transparent
        int argb = image.getRGB(x, y);
        return (argb >> 24) != 0;
    }

    /**
     * Computes the bounds of the smallest rectangle that contains all non-transparent pixels of
     * this image. This isn't extremely efficient, so you shouldn't be doing this anywhere
     * exciting.
     */
    public static void computeTrimmedBounds (BufferedImage image, Rectangle tbounds)
    {
        // this could be more efficient, but it's run as a batch process and doesn't really take
        // that long anyway
        int width = image.getWidth(), height = image.getHeight();

        int firstrow = -1, lastrow = -1, minx = width, maxx = 0;
        for (int yy = 0; yy < height; yy++) {

            int firstidx = -1, lastidx = -1;
            for (int xx = 0; xx < width; xx++) {
                // if this pixel is transparent, do nothing
                int argb = image.getRGB(xx, yy);
                if ((argb >> 24) == 0) {
                    continue;
                }

                // otherwise, if we've not seen a non-transparent pixel, make a note that this is
                // the first non-transparent pixel in the row
                if (firstidx == -1) {
                    firstidx = xx;
                }
                // keep track of the last non-transparent pixel we saw
                lastidx = xx;
            }

            // if we saw no pixels on this row, we can bail now
            if (firstidx == -1) {
                continue;
            }

            // update our min and maxx
            minx = Math.min(firstidx, minx);
            maxx = Math.max(lastidx, maxx);

            // otherwise keep track of the first row on which we see pixels and the last row on
            // which we see pixels
            if (firstrow == -1) {
                firstrow = yy;
            }
            lastrow = yy;
        }

        // fill in the dimensions
        if (firstrow != -1) {
            tbounds.x = minx;
            tbounds.y = firstrow;
            tbounds.width = maxx - minx + 1;
            tbounds.height = lastrow - firstrow + 1;
        } else {
            // Entirely blank image.  Return 1x1 blank image.
            tbounds.x = 0;
            tbounds.y = 0;
            tbounds.width = 1;
            tbounds.height = 1;
        }
    }

    /**
     * Returns the estimated memory usage in bytes for the specified image.
     */
    public static long getEstimatedMemoryUsage (BufferedImage image)
    {
        if (image != null) {
            return getEstimatedMemoryUsage(image.getRaster());
        } else {
            return 0;
        }
    }

    /**
     * Returns the estimated memory usage in bytes for the specified raster.
     */
    public static long getEstimatedMemoryUsage (Raster raster)
    {
        // we assume that the data buffer stores each element in a byte-rounded memory element;
        // maybe the buffer is smarter about things than this, but we're better to err on the safe
        // side
        DataBuffer db = raster.getDataBuffer();
        int bpe = (int)Math.ceil(DataBuffer.getDataTypeSize(db.getDataType()) / 8f);
        return bpe * db.getSize();
    }

    /**
     * Returns the estimated memory usage in bytes for all buffered images in the supplied
     * iterator.
     */
    public static long getEstimatedMemoryUsage (Iterator<BufferedImage> iter)
    {
        long size = 0;
        while (iter.hasNext()) {
            BufferedImage image = iter.next();
            size += getEstimatedMemoryUsage(image);
        }
        return size;
    }

    /**
     * Obtains the default graphics configuration for this VM. If the JVM is in headless mode,
     * this method will return null.
     */
    protected static GraphicsConfiguration getDefGC ()
    {
        if (_gc == null) {
            // obtain information on our graphics environment
            try {
                GraphicsEnvironment env = GraphicsEnvironment.getLocalGraphicsEnvironment();
                GraphicsDevice gd = env.getDefaultScreenDevice();
                _gc = gd.getDefaultConfiguration();
            } catch (HeadlessException e) {
                // no problem, just return null
            }
        }
        return _gc;
    }

    /** The graphics configuration for the default screen device. */
    protected static GraphicsConfiguration _gc;

    /** Used when seeking fully transparent pixels for outlining. */
    protected static final int TRANS_MASK = (0xFF << 24);

    /** Used when outlining. */
    protected static final int RGB_MASK = 0x00FFFFFF;
}
