/*
 * 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 "TwelveMonkeys" 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 OWNER 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.image;

import com.twelvemonkeys.lang.Validate;

import java.awt.*;
import java.awt.image.*;
import java.lang.reflect.Array;
import java.util.EventListener;
import java.util.Hashtable;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * A faster, lighter and easier way to convert an {@code Image} to a
 * {@code BufferedImage} than using a {@code PixelGrabber}.
 * Clients may provide progress listeners to monitor conversion progress.
 * <p/>
 * Supports source image subsampling and source region extraction.
 * Supports source images with 16 bit {@link ColorModel} and
 * {@link DataBuffer#TYPE_USHORT} transfer type, without converting to
 * 32 bit/TYPE_INT.
 * <p/>
 * NOTE: Does not support images with more than one {@code ColorModel} or
 * different types of pixel data. This is not very common.
 *
 * @author <a href="mailto:harald.kuhr@gmail.com">Harald Kuhr</a>
 * @version $Id: //depot/branches/personal/haraldk/twelvemonkeys/release-2/twelvemonkeys-core/src/main/java/com/twelvemonkeys/image/BufferedImageFactory.java#1 $
 */
public final class BufferedImageFactory {
    private List<ProgressListener> listeners;
    private int percentageDone;

    private ImageProducer producer;
    private ImageConversionException consumerException;
    private volatile boolean fetching;
    private boolean readColorModelOnly;

    private int x = 0;
    private int y = 0;
    private int width = -1;
    private int height = -1;

    private int xSub = 1;
    private int ySub = 1;

    private int offset;
    private int scanSize;

    private ColorModel sourceColorModel;
    private Hashtable sourceProperties; // ImageConsumer API dictates Hashtable

    private Object sourcePixels;

    private BufferedImage buffered;
    private ColorModel colorModel;

    // NOTE: Just to not expose the inheritance
    private final Consumer consumer = new Consumer();

    /**
     * Creates a {@code BufferedImageFactory}.
     * @param pSource the source image
     * @throws IllegalArgumentException if {@code pSource == null}
     */
    public BufferedImageFactory(final Image pSource) {
        this(pSource != null ? pSource.getSource() : null);
    }

    /**
     * Creates a {@code BufferedImageFactory}.
     * @param pSource the source image producer
     * @throws IllegalArgumentException if {@code pSource == null}
     */
    public BufferedImageFactory(final ImageProducer pSource) {
        Validate.notNull(pSource, "source");
        producer = pSource;
    }

    /**
     * Returns the {@code BufferedImage} extracted from the given
     * {@code ImageSource}. Multiple requests will return the same image.
     *
     * @return the {@code BufferedImage}
     *
     * @throws ImageConversionException if the given {@code ImageSource} cannot
     * be converted for some reason.
     */
    public BufferedImage getBufferedImage() throws ImageConversionException {
        doFetch(false);
        return buffered;
    }

    /**
     * Returns the {@code ColorModel} extracted from the
     * given {@code ImageSource}. Multiple requests will return the same model.
     *
     * @return the {@code ColorModel}
     *
     * @throws ImageConversionException if the given {@code ImageSource} cannot
     * be converted for some reason.
     */
    public ColorModel getColorModel() throws ImageConversionException {
        doFetch(true);
        return buffered != null ? buffered.getColorModel() : colorModel;
    }

    /**
     * Frees resources used by this {@code BufferedImageFactory}.
     */
    public void dispose() {
        freeResources();
        buffered = null;
        colorModel = null;
    }

    /**
     * Aborts the image production.
     */
    public void abort() {
        consumer.imageComplete(ImageConsumer.IMAGEABORTED);
    }

    /**
     * Sets the source region (AOI) for the new image.
     *
     * @param pRegion the source region
     */
    public void setSourceRegion(final Rectangle pRegion) {
        // Re-fetch everything, if region changed
        if (x != pRegion.x || y != pRegion.y || width != pRegion.width || height != pRegion.height) {
            dispose();
        }

        x = pRegion.x;
        y = pRegion.y;
        width = pRegion.width;
        height = pRegion.height;
    }

    /**
     * Sets the source subsampling for the new image.
     *
     * @param pXSub horizontal subsampling factor
     * @param pYSub vertical subsampling factor
     */
    public void setSourceSubsampling(int pXSub, int pYSub) {
        // Re-fetch everything, if subsampling changed
        if (xSub != pXSub || ySub != pYSub) {
            dispose();
        }

        if (pXSub > 1) {
            xSub = pXSub;
        }
        if (pYSub > 1) {
            ySub = pYSub;
        }
    }

    private synchronized void doFetch(boolean pColorModelOnly) throws ImageConversionException {
        if (!fetching && (!pColorModelOnly && buffered == null || buffered == null && sourceColorModel == null)) {
            // NOTE: Subsampling is only applied if extracting full image
            if (!pColorModelOnly && (xSub > 1 || ySub > 1)) {
                // If only sampling a region, the region must be scaled too
                if (width > 0 && height > 0) {
                    width = (width + xSub - 1) / xSub;
                    height = (height + ySub - 1) / ySub;

                    x = (x + xSub - 1) / xSub;
                    y = (y + ySub - 1) / ySub;
                }

                producer = new FilteredImageSource(producer, new SubsamplingFilter(xSub, ySub));
            }

            // Start fetching
            fetching = true;
            readColorModelOnly = pColorModelOnly;

            producer.startProduction(consumer); // Note: If single-thread (synchronous), this call will block

            // Wait until the producer wakes us up, by calling imageComplete
            while (fetching) {
                try {
                    wait(200l);
                }
                catch (InterruptedException e) {
                    throw new ImageConversionException("Image conversion aborted: " + e.getMessage(), e);
                }
            }

            if (consumerException != null) {
                throw new ImageConversionException("Image conversion failed: " + consumerException.getMessage(), consumerException);
            }

            if (pColorModelOnly) {
                createColorModel();
            }
            else {
                createBuffered();
            }
        }
    }

    private void createColorModel() {
        colorModel = sourceColorModel;

        // Clean up, in case any objects are copied/cloned, so we can free resources
        freeResources();
    }

    private void createBuffered() {
        if (width > 0 && height > 0) {
            if (sourceColorModel != null && sourcePixels != null) {
                // TODO: Fix pixel size / color model problem
                WritableRaster raster = ImageUtil.createRaster(width, height, sourcePixels, sourceColorModel);
                buffered = new BufferedImage(sourceColorModel, raster, sourceColorModel.isAlphaPremultiplied(), sourceProperties);
            }
            else {
                buffered = ImageUtil.createClear(width, height, null);
            }
        }

        // Clean up, in case any objects are copied/cloned, so we can free resources
        freeResources();
    }

    private void freeResources() {
        sourceColorModel = null;
        sourcePixels = null;
        sourceProperties = null;
    }

    private void processProgress(int scanline) {
        if (listeners != null) {
            int percent = 100 * scanline / height;

            if (percent > percentageDone) {
                percentageDone = percent;

                for (ProgressListener listener : listeners) {
                    listener.progress(this, percent);
                }
            }
        }
    }

    /**
     * Adds a progress listener to this factory.
     *
     * @param pListener the progress listener
     */
    public void addProgressListener(ProgressListener pListener) {
        if (pListener == null) {
            return;
        }

        if (listeners == null) {
            listeners = new CopyOnWriteArrayList<ProgressListener>();
        }

        listeners.add(pListener);
    }

    /**
     * Removes a progress listener from this factory.
     *
     * @param pListener the progress listener
     */
    public void removeProgressListener(ProgressListener pListener) {
        if (pListener == null) {
            return;
        }

        if (listeners == null) {
            return;
        }

        listeners.remove(pListener);
    }

    /**
     * Removes all progress listeners from this factory.
     */
    public void removeAllProgressListeners() {
        if (listeners != null) {
            listeners.clear();
        }
    }

    /**
     * Converts an array of {@code int} pixels to an array of {@code short}
     * pixels. The conversion is done, by masking out the
     * <em>higher 16 bits</em> of the {@code int}.
     *
     * For any given {@code int}, the {@code short} value is computed as
     * follows:
     * <blockquote>{@code
     * short value = (short) (intValue & 0x0000ffff);
     * }</blockquote>
     *
     * @param pPixels the pixel data to convert
     * @return an array of {@code short}s, same lenght as {@code pPixels}
     */
    private static short[] toShortPixels(int[] pPixels) {
        short[] pixels = new short[pPixels.length];

        for (int i = 0; i < pixels.length; i++) {
            pixels[i] = (short) (pPixels[i] & 0xffff);
        }

        return pixels;
    }

    /**
     * This interface allows clients of a {@code BufferedImageFactory} to
     * receive notifications of decoding progress.
     *
     * @see BufferedImageFactory#addProgressListener
     * @see BufferedImageFactory#removeProgressListener
     */
    public static interface ProgressListener extends EventListener {

        /**
         * Reports progress to this listener.
         * Invoked by the {@code BufferedImageFactory} to report progress in
         * the image decoding.
         *
         * @param pFactory the factory reporting the progress
         * @param pPercentage the percentage of progress
         */
        void progress(BufferedImageFactory pFactory, float pPercentage);
    }

    private class Consumer implements ImageConsumer {
        /**
         * Implementation of all setPixels methods.
         * Note that this implementation assumes that all invocations for one
         * image uses the same color model, and that the pixel data has the
         * same type.
         *
         * @param pX x coordinate of pixel data region
         * @param pY y coordinate of pixel data region
         * @param pWidth width of pixel data region
         * @param pHeight height of pixel data region
         * @param pModel the color model of the pixel data
         * @param pPixels the pixel data array
         * @param pOffset the offset into the pixel data array
         * @param pScanSize the scan size of the pixel data array
         */
        @SuppressWarnings({"SuspiciousSystemArraycopy"})
        private void setPixelsImpl(int pX, int pY, int pWidth, int pHeight, ColorModel pModel, Object pPixels, int pOffset, int pScanSize) {
            setColorModelOnce(pModel);

            if (pPixels == null) {
                return;
            }

            // Allocate array if necessary
            if (sourcePixels == null) {
                // Allocate a suitable source pixel array
                // TODO: Should take pixel "width" into consideration, for byte packed rasters?!
                // OR... Is anything but single-pixel models really supported by the API?
                sourcePixels = Array.newInstance(pPixels.getClass().getComponentType(), width * height);
                scanSize = width;
                offset = 0;
            }
            else if (sourcePixels.getClass() != pPixels.getClass()) {
                throw new IllegalStateException("Only one pixel type allowed");
            }

            // AOI stuff
            if (pY < y) {
                int diff = y - pY;
                if (diff >= pHeight) {
                    return;
                }
                pOffset += pScanSize * diff;
                pY += diff;
                pHeight -= diff;
            }
            if (pY + pHeight > y + height) {
                pHeight = (y + height) - pY;
                if (pHeight <= 0) {
                    return;
                }
            }

            if (pX < x) {
                int diff = x - pX;
                if (diff >= pWidth) {
                    return;
                }
                pOffset += diff;
                pX += diff;
                pWidth -= diff;
            }
            if (pX + pWidth > x + width) {
                pWidth = (x + width) - pX;
                if (pWidth <= 0) {
                    return;
                }
            }

            int dstOffset = offset + (pY - y) * scanSize + (pX - x);

            // Do the pixel copying
            for (int i = pHeight; i > 0; i--) {
                System.arraycopy(pPixels, pOffset, sourcePixels, dstOffset, pWidth);
                pOffset += pScanSize;
                dstOffset += scanSize;
            }

            processProgress(pY + pHeight);
        }

        public void setPixels(int pX, int pY, int pWidth, int pHeight, ColorModel pModel, short[] pPixels, int pOffset, int pScanSize) {
            setPixelsImpl(pX, pY, pWidth, pHeight, pModel, pPixels, pOffset, pScanSize);
        }

        private void setColorModelOnce(final ColorModel pModel) {
            // NOTE: There seems to be a "bug" in AreaAveragingScaleFilter, as it
            // first passes the original color model through in setColorModel, then
            // later replaces it with the default RGB in the first setPixels call
            // (this is probably allowed according to the spec, but it's a waste of time and space).
            if (sourceColorModel != pModel) {
                if (/*sourceColorModel == null ||*/ sourcePixels == null) {
                    sourceColorModel = pModel;
                }
                else {
                    throw new IllegalStateException("Change of ColorModel after pixel delivery not supported");
                }
            }

            // If color model is all we ask for, stop now
            if (readColorModelOnly) {
                consumer.imageComplete(ImageConsumer.IMAGEABORTED);
            }
        }

        public void imageComplete(int pStatus) {
            fetching = false;

            if (producer != null) {
                producer.removeConsumer(this);
            }

            switch (pStatus) {
                case ImageConsumer.IMAGEERROR:
                    consumerException = new ImageConversionException("ImageConsumer.IMAGEERROR");
                break;
            }

            synchronized (BufferedImageFactory.this) {
                BufferedImageFactory.this.notifyAll();
            }
        }

        public void setColorModel(ColorModel pModel) {
            setColorModelOnce(pModel);
        }

        public void setDimensions(int pWidth, int pHeight) {
            if (width < 0) {
                width = pWidth - x;
            }
            if (height < 0) {
                height = pHeight - y;
            }

            // Hmm.. Special case, but is it a good idea?
            if (width <= 0 || height <= 0) {
                imageComplete(ImageConsumer.STATICIMAGEDONE);
            }
        }

        public void setHints(int pHintflags) {
           // ignore
        }

        public void setPixels(int pX, int pY, int pWidth, int pHeight, ColorModel pModel, byte[] pPixels, int pOffset, int pScanSize) {
            setPixelsImpl(pX, pY, pWidth, pHeight, pModel, pPixels, pOffset, pScanSize);
        }

        public void setPixels(int pX, int pY, int pWeigth, int pHeight, ColorModel pModel, int[] pPixels, int pOffset, int pScanSize) {
            if (pModel.getTransferType() == DataBuffer.TYPE_USHORT) {
                // NOTE: Workaround for limitation in ImageConsumer API
                // Convert int[] to short[], to be compatible with the ColorModel
                setPixelsImpl(pX, pY, pWeigth, pHeight, pModel, toShortPixels(pPixels), pOffset, pScanSize);
            }
            else {
                setPixelsImpl(pX, pY, pWeigth, pHeight, pModel, pPixels, pOffset, pScanSize);
            }
        }

        public void setProperties(Hashtable pProperties) {
            sourceProperties = pProperties;
        }
    }

    /*
    public static void main(String[] args) throws InterruptedException {
        Image image = Toolkit.getDefaultToolkit().createImage(args[0]);
        System.err.printf("image: %s (which is %sa buffered image)\n", image, image instanceof BufferedImage ? "" : "not ");

        int warmUpLoops = 500;
        int testLoops = 100;

        for (int i = 0; i < warmUpLoops; i++) {
            // Warm up...
            convertUsingFactory(image);
            convertUsingPixelGrabber(image);
            convertUsingPixelGrabberNaive(image);
        }

        BufferedImage bufferedImage = null;
        long start = System.currentTimeMillis();
        for (int i = 0; i < testLoops; i++) {
            bufferedImage = convertUsingFactory(image);
        }
        System.err.printf("Conversion time (factory): %f ms (image: %s)\n", (System.currentTimeMillis() - start) / (double) testLoops, bufferedImage);

        start = System.currentTimeMillis();
        for (int i = 0; i < testLoops; i++) {
            bufferedImage = convertUsingPixelGrabber(image);
        }
        System.err.printf("Conversion time (grabber): %f ms (image: %s)\n", (System.currentTimeMillis() - start) / (double) testLoops, bufferedImage);

        start = System.currentTimeMillis();
        for (int i = 0; i < testLoops; i++) {
            bufferedImage = convertUsingPixelGrabberNaive(image);
        }
        System.err.printf("Conversion time (naive g): %f ms (image: %s)\n", (System.currentTimeMillis() - start) / (double) testLoops, bufferedImage);
    }

    private static BufferedImage convertUsingPixelGrabberNaive(Image image) throws InterruptedException {
        // NOTE: It does not matter if we wait for the image or not, the time is about the same as it will only happen once
        if ((image.getWidth(null) < 0 || image.getHeight(null) < 0) && !ImageUtil.waitForImage(image)) {
            System.err.printf("Could not get image dimensions for image %s\n", image.getSource());
        }

        int w = image.getWidth(null);
        int h = image.getHeight(null);
        PixelGrabber grabber = new PixelGrabber(image, 0, 0, w, h, true); // force RGB
        grabber.grabPixels();

        // Following casts are safe, as we force RGB in the pixel grabber
        int[] pixels = (int[]) grabber.getPixels();

        BufferedImage bufferedImage = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
//        bufferedImage.setRGB(0, 0, w, h, pixels, 0, w);
        bufferedImage.getRaster().setDataElements(0, 0, w, h, pixels);

        return bufferedImage;
    }

    private static BufferedImage convertUsingPixelGrabber(Image image) throws InterruptedException {
        // NOTE: It does not matter if we wait for the image or not, the time is about the same as it will only happen once
        if ((image.getWidth(null) < 0 || image.getHeight(null) < 0) && !ImageUtil.waitForImage(image)) {
            System.err.printf("Could not get image dimensions for image %s\n", image.getSource());
        }

        int w = image.getWidth(null);
        int h = image.getHeight(null);
        PixelGrabber grabber = new PixelGrabber(image, 0, 0, w, h, true); // force RGB
        grabber.grabPixels();

        // Following casts are safe, as we force RGB in the pixel grabber
//        DirectColorModel cm = (DirectColorModel) grabber.getColorModel();
        DirectColorModel cm = (DirectColorModel) ColorModel.getRGBdefault();
        int[] pixels = (int[]) grabber.getPixels();

        WritableRaster raster = Raster.createPackedRaster(new DataBufferInt(pixels, pixels.length), w, h, w, cm.getMasks(), null);

        return new BufferedImage(cm, raster, cm.isAlphaPremultiplied(), null);
    }

    private static BufferedImage convertUsingFactory(Image image) {
        return new BufferedImageFactory(image).getBufferedImage();
    }
    */
}