/*
 *    GeoTools - OpenSource mapping toolkit
 *    http://geotools.org
 *    (C) 2006, Geotools Project Managment Committee (PMC)
 *
 *    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.
 */
package org.geotools.coverage.processing;

import java.awt.Color;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import javax.units.Unit;
import javax.units.ConversionException;

import org.opengis.util.InternationalString;
import org.opengis.referencing.operation.MathTransform1D;
import org.opengis.referencing.operation.TransformException;

import org.geotools.coverage.Category;
import org.geotools.coverage.GridSampleDimension;
import org.geotools.io.TableWriter;
import org.geotools.util.logging.Logging;
import org.geotools.util.NumberRange;
import org.geotools.util.MeasurementRange;
import org.geotools.resources.Utilities;
import org.geotools.resources.i18n.Errors;
import org.geotools.resources.i18n.ErrorKeys;
import org.geotools.resources.i18n.Vocabulary;
import org.geotools.resources.i18n.VocabularyKeys;
import org.geotools.resources.image.ColorUtilities;


/**
 * Colors associated to categories. This is the parameter type for the
 * {@link org.geotools.coverage.processing.operation.Recolor} operation.
 * 
 * @since 2.4
 * @source $URL: http://svn.geotools.org/geotools/tags/2.4.1/modules/library/coverage/src/main/java/org/geotools/coverage/processing/ColorMap.java $
 * @version $Id: ColorMap.java 27848 2007-11-12 13:10:32Z desruisseaux $
 * @author Martin Desruisseaux
 *
 * @see org.geotools.coverage.processing.operation.Recolor
 *
 * @todo We need to investigage if this object should be defined as an implementation of
 *       {@link org.geotools.styling.ColorMap}.
 */
public class ColorMap implements Serializable {
    /**
     * For cross-version compatibility.
     */
    private static final long serialVersionUID = 1688030908496323012L;

    /**
     * A special category name meaning "<cite>any quantitative value</cite>".
     */
    public static final CharSequence ANY_QUANTITATIVE_CATEGORY =
            Vocabulary.formatInternational(VocabularyKeys.ALL); // TODO: Find a better name.

    /**
     * The colors to apply to categories. Keys are {@link String} objects.
     * Values may be {@link Color} singletons or {@code Color[]} arrays.
     * <p>
     * The {@link #ANY_QUANTITATIVE_CATEGORY} key is replaced by {@code null} in
     * order to avoid confusion with user-specified category with the exact name.
     */
    private Map/*<String,Object>*/ colorMap;

    /**
     * The range of values for quantitative categories. Values are {@link NumberRange} instances
     * if the range is relative, or {@link MeasurementRange} if the range is geophysics.
     * <p>
     * The {@link #ANY_QUANTITATIVE_CATEGORY} key is replaced by {@code null} in
     * order to avoid confusion with user-specified category with the exact name.
     */
    private Map/*<String,NumberRange>*/ colorRanges;

    /**
     * If {@code true}, the ARGB values corresponding to any {@linkplain Category category}
     * <strong>not</strong> specified in this color map will be reset to the color specified
     * by the category. The default value is {@code false}.
     */
    private boolean resetUnspecifiedColors;

    /**
     * Creates an initially empty color map.
     */
    public ColorMap() {
    }

    /**
     * Creates a color map initialized to the specified color ramp to be applied on
     * {@linkplain #ANY_QUANTITATIVE_CATEGORY any quantitative category}.
     */
    public ColorMap(final Color[] colors) {
        setColors(ANY_QUANTITATIVE_CATEGORY, colors);
    }

    /**
     * Creates a color map initialized to the specified map.
     *
     * @param map A map of ({@linkplain Category#getName category name},
     *        {@linkplain Color colors}) pairs.
     */
    public ColorMap(final Map/*<? extends CharSequence,Color[]>*/ colorMap) {
        for (final Iterator it=colorMap.entrySet().iterator(); it.hasNext();) {
            final Map.Entry entry = (Map.Entry) it.next();
            setColors((CharSequence) entry.getKey(), (Color[]) entry.getValue());
        }
    }

    /**
     * Returns the unlocalized flavor of the given name
     * (not to be confused with the default locale).
     *
     * @param category The {@linkplain Category#getName category name}.
     * @return The unlocalized name, or {@code null}.
     */
    private static String unlocalized(final CharSequence name) {
        if (name == ANY_QUANTITATIVE_CATEGORY) {
            return null;
        }
        if (name instanceof InternationalString) {
            return ((InternationalString) name).toString(null);
        } else {
            return name.toString();
        }
    }

    /**
     * Applies colors to the given category.
     *
     * @param category The {@linkplain Category#getName name of the category}
     *        for which to set the colors.
     * @param colors Colors to apply to the specified category, or {@code null}.
     */
    private void setColorObject(final CharSequence category, final Object colors) {
        final String name = unlocalized(category);
        if (colors != null) {
            if (colorMap == null) {
                colorMap = new HashMap();
            }
            colorMap.put(name, colors);
        } else if (colorMap != null) {
            colorMap.remove(name);
            if (colorMap.isEmpty()) {
                colorMap = null; // For more accurate 'equals' implementation.
            }
        }
    }

    /**
     * Applies a uniform color to the given (usually <cite>qualitative</cite>) category.
     *
     * @param category The {@linkplain Category#getName name of the category}
     *        for which to set the color.
     * @param color A uniform color to apply to the specified category, or {@code null}
     *        for removing the color mapping.
     *
     * @see #recolor
     */
    public void setColor(final CharSequence category, final Color color) {
        setColorObject(category, color);
    }

    /**
     * Applies a color ramp to the given (usually <cite>quantitative</cite>) category.
     * The color array may have any length; colors will be interpolated as needed.
     *
     * @param category The {@linkplain Category#getName name of the category} for which to set
     *        the colors, or {@link #ANY_QUANTITATIVE_CATEGORY} if the colors should apply to
     *        any quantitative category.
     * @param colors The colors to apply to the specified category, or {@code null}
     *        or an empty array for removing the color mapping.
     *
     * @see #recolor
     */
    public void setColors(final CharSequence category, final Color[] colors) {
        final Object value;
        if (colors != null) {
            switch (colors.length) {
                default: value = colors.clone(); break;
                case 1:  value = colors[0];      break;
                case 0:  value = null;           break;
            }
        } else {
            value = null;
        }
        setColorObject(category, value);
    }

    /**
     * Returns the color ramp for the given category.
     *
     * @param  category The {@linkplain Category#getName category name}, or
     *         {@link #ANY_QUANTITATIVE_CATEGORY} for fetching the colors to
     *         apply to any quantitative category.
     * @return The color ramp, or {@code null} if none.
     */
    public Color[] getColors(final CharSequence category) {
        if (colorMap == null) {
            return null;
        }
        final String name = unlocalized(category);
        Object colors = colorMap.get(name);
        if (colors == null) {
            if (name!=null && category instanceof InternationalString) {
                // Unlocalized name not found. Search using the localized flavor.
                colors = getColors(category.toString());
                if (colors == null) {
                    return null;
                }
            } else {
                return null;
            }
        }
        if (colors instanceof Color) {
            return new Color[] {(Color) colors};
        }
        return (Color[]) ((Color[]) colors).clone();
    }

    /**
     * Sets a range of geophysics values for the color ramp associated with a quantitative category.
     * For example if the category "<cite>Height</cite>" applies to geophysics values in the range
     * [0..500] metres and if a range of [100..400] metres is defined as below:
     *
     * <blockquote><code>
     * setRelativeRange("Height", new MeasurementRange(0, 100, SI.METRE));
     * setColors("Height", myColorPalette);
     * </code><blockquote>
     *
     * Then {@code myColorPalette} will applies to pixel values in the range [100..400] instead
     * of [0..500]. This is typically used in order to augment the contrast in a range of values
     * of special interest.
     * <p>
     * This method is exclusive with {@link #setRelativeRange}.
     *
     * @param category The {@linkplain Category#getName name of the category}
     *        for which to set the geophysics range.
     * @param range The minimal and maximal values for the color ramp. A {@code null}
     *        value removes the range mapping.
     *
     * @see #recolor
     */
    public void setGeophysicsRange(final CharSequence category, final MeasurementRange range) {
        setRange(category, range);
    }

    /**
     * Sets a relative range of values for the color ramp associated to a quantitative category.
     * For example if the category "<cite>Height</cite>" applies to pixel values in the range
     * [0..200] and if a relative range of [20%..80%] is defined as below:
     *
     * <blockquote><code>
     * setRelativeRange("Height", new NumberRange(20, 80));
     * setColors("Height", myColorPalette);
     * </code><blockquote>
     *
     * Then {@code myColorPalette} will applies to pixel values in the range [40..160] instead
     * of [0..200]. This is typically used in order to augment the contrast in a range of values
     * of special interest.
     * <p>
     * This method is exclusive with {@link #setGeophysicsRange}.
     *
     * @param category The {@linkplain Category#getName name of the category}
     *        for which to set the relative range.
     * @param range The minimal and maximal relative values for the color ramp, as percentages
     *        between 0 and 100. A {@code null} value removes the range mapping.
     *
     * @see #recolor
     */
    public void setRelativeRange(final CharSequence category, final NumberRange range) {
        if (range instanceof MeasurementRange) {
            // The MeasurementRange type is reserved for geophysics ranges.
            throw new IllegalArgumentException(Errors.format(ErrorKeys.ILLEGAL_ARGUMENT_$1, "range"));
        }
        setRange(category, range);
    }

    /**
     * Sets a relative or geophysics range.
     * This method is exclusive with {@link #setGeophysicsRange}.
     *
     * @param category The {@linkplain Category#getName name of the category}
     *        for which to set the relative or geophysics range.
     * @param range The minimal and maximal values for the color ramp.
     */
    private void setRange(final CharSequence category, final NumberRange range) {
        final String name = unlocalized(category);
        if (range != null) {
            if (colorRanges == null) {
                colorRanges = new HashMap();
            }
            colorRanges.put(name, range);
        } else if (colorRanges != null) {
            colorRanges.remove(name);
            if (colorRanges.isEmpty()) {
                colorRanges = null; // For more accurate 'equals' implementation.
            }
        }
    }

    /**
     * Returns the range of geophysics values for the given category.
     *
     * @param  category The {@linkplain Category#getName category name}, or
     *         {@link #ANY_QUANTITATIVE_CATEGORY} for fetching the range to
     *         apply to any quantitative category.
     * @return The geophysics range, or {@code null} if none.
     */
    public MeasurementRange getGeophysicsRange(final CharSequence category) {
        final NumberRange range = getRange(category);
        return (range instanceof MeasurementRange) ? (MeasurementRange) range : null;
    }

    /**
     * Returns the relative range of values for the given category.
     *
     * @param  category The {@linkplain Category#getName category name}, or
     *         {@link #ANY_QUANTITATIVE_CATEGORY} for fetching the relative
     *         range to apply to any quantitative category.
     * @return The relative range, or {@code null} if none.
     */
    public NumberRange getRelativeRange(final CharSequence category) {
        final NumberRange range = getRange(category);
        return (range instanceof MeasurementRange) ? null : range;
    }

    /**
     * Returns the range of relative or geophysics values. If the returned range is an instance of
     * {@link MeasurementRange}, then is is a {@linkplain #getGeophysicsRange geophysics range}.
     * Otherwise it is a {@linkplain #getRelativeRange relative range}.
     *
     * @param  category The {@linkplain Category#getName category name}, or
     *         {@link #ANY_QUANTITATIVE_CATEGORY} for fetching the range to
     *         apply to any quantitative category.
     * @return The relative or geophysics range, or {@code null} if none.
     */
    private NumberRange getRange(final CharSequence category) {
        if (colorRanges == null) {
            return null;
        }
        final String name = unlocalized(category);
        NumberRange range = (NumberRange) colorRanges.get(name);
        if (range == null) {
            if (name!=null && category instanceof InternationalString) {
                // Unlocalized name not found. Search using the localized flavor.
                range = (NumberRange) colorRanges.get(category.toString());
            }
        }
        return range;
    }

    /**
     * Returns the range of sample values for the given category, or {@code null} if none.
     * This range is computed from the {@linkplain #getRange relative or geophysics range}.
     *
     * @param  category The category for which to compute the range.
     * @param  units The category units, usually {@link GridSampleDimension#getUnits}.
     * @return The range, or {@code null} if none. The lower index is always inclusive
     *         and the upper index is always exclusive.
     */
    private NumberRange getTargetRange(final Category category, final Unit units) {
        NumberRange scale = getRange(category.getName());
        if (scale == null) {
            if (category.isQuantitative()) {
                scale = getRange(ANY_QUANTITATIVE_CATEGORY);
            }
            if (scale == null) {
                return null;
            }
        }
        double  minimum     = scale.getMinimum();
        double  maximum     = scale.getMaximum();
        boolean minIncluded = scale.isMinIncluded();
        boolean maxIncluded = scale.isMaxIncluded();
        if (scale instanceof MeasurementRange) {
            try {
                scale = ((MeasurementRange) scale).convertTo(units);
            } catch (ConversionException e) {
                Logging.unexpectedException(AbstractProcessor.LOGGER, ColorMap.class, "recolor", e);
                return null; // This is allowed by this method contract.
            }
            MathTransform1D tr = category.getSampleToGeophysics();
            if (tr != null) try {
                tr = (MathTransform1D) tr.inverse();
                minimum = tr.transform(minimum);
                maximum = tr.transform(maximum);
            } catch (TransformException e) {
                Logging.unexpectedException(AbstractProcessor.LOGGER, ColorMap.class, "recolor", e);
                return null; // This is allowed by this method contract.
            }
        } else {
            final NumberRange range = category.getRange();
            final double lower  = range.getMinimum();
            final double extent = range.getMaximum() - lower;
            minimum     = (minimum / 100) * extent + lower;
            maximum     = (maximum / 100) * extent + lower;
            minIncluded &= range.isMinIncluded();
            maxIncluded &= range.isMaxIncluded();
        }
        final int lower, upper;
        if (minimum > maximum) {
            lower = round(maximum,  maxIncluded);
            upper = round(minimum, !minIncluded);
        } else {
            lower = round(minimum,  minIncluded);
            upper = round(maximum, !maxIncluded);
        }
        return new NumberRange(lower, true, upper, false);
    }

    /**
     * Round the specified number to the {@linkplain Math#floor lower} or {@linkplain Math#ceil
     * upper} value, depending if the value is inclusive or not. This method is appropriate for
     * minimal range value. In order to apply it to the maximal range value, {@code included}
     * must be replaced by {@code !included}.
     */
    private static int round(final double value, final boolean included) {
        final double rounded = included ? Math.floor(value) : Math.ceil(value);
        int asInteger = (int) rounded;
        if (!included && value == rounded) {
            asInteger++;
        }
        return asInteger;
    }

    /**
     * If {@code true}, the ARGB values corresponding to any {@linkplain Category category}
     * <strong>not</strong> specified in this color map will be reset to the color specified
     * by the category. The default value is {@code false}.
     */
    public void setResetUnspecifiedColors(final boolean reset) {
        resetUnspecifiedColors = reset;
    }

    /**
     * If {@code true}, the ARGB values corresponding to any {@linkplain Category category}
     * <strong>not</strong> specified in this color map will be reset to the color specified
     * by the category. The default value is {@code false}.
     */
    public boolean getResetUnspecifiedColors() {
        return resetUnspecifiedColors;
    }

    /**
     * Applies to the specified sample dimension the colors given to this color map. This method
     * iterates throug every {@linkplain Category categories} in the given sample dimension. For
     * each category with a {@linkplain Category#getName name} matching one of the (<var>name</var>,
     * <var>colors</var>) or (<var>name</var>, <var>range</var>) entries given to this color map,
     * the {@link Category#recolor recolor} method is invoked on that category and the result
     * inserted into a new sample dimension to be returned.
     * <p>
     * If the optional {@code ARGB} array is non-null, then the ARGB colors for recolorized
     * categories will be written in this array. Only the elements with index in the
     * {@linkplain Category#getRange category range} will be overwritten; other elements
     * will not be modified.
     * <p>
     * <strong>NOTE:</strong> The {@linkplain #setGeophysicsRange geophysics} and
     * {@linkplain #setRelativeRange relative} ranges are taken in account for the
     * {@code ARGB} array only; they do not have impact on the categories to be
     * included in the returned sample dimension.
     *
     * @param  sampleDimension The sample dimension to recolorize.
     * @param  ARGB An optional array where to store the ARGB values of recolorized categories,
     *         or {@code null} if none.
     * @return A new sample dimension, or {@code sampleDimension} if no color change were applied.
     *
     * @see Category#recolor
     */
    public GridSampleDimension recolor(final GridSampleDimension sampleDimension, final int[] ARGB) {
        final GridSampleDimension displayDimension = sampleDimension.geophysics(false);
        boolean changed = false;
        final Category categories[] = (Category[]) displayDimension.getCategories().toArray();
        for (int i=0; i<categories.length; i++) {
            Category category = categories[i];
            Color[] colors = getColors(category.getName());
            if (colors == null) {
                if (category.isQuantitative()) {
                    colors = getColors(ANY_QUANTITATIVE_CATEGORY);
                }
                if (colors == null && resetUnspecifiedColors) {
                    colors = category.getColors();
                }
                // 'colors' may still null, so we will need to check.
            }
            if (ARGB != null) {
                final NumberRange range = category.getRange();
                // TODO: Remove casts when we will be allowed to compile for J2SE 1.5.
                int lower = ((Number) range.getMinValue()).intValue();
                int upper = ((Number) range.getMaxValue()).intValue();
                if (!range.isMinIncluded()) lower++;
                if ( range.isMaxIncluded()) upper++;
                boolean outOfBounds = false;
                if (lower < 0) {
                    lower = 0;
                    outOfBounds = true;
                }
                if (upper > ARGB.length) {
                    upper = ARGB.length;
                    outOfBounds = true;
                }
                if (outOfBounds) {
                    AbstractProcessor.LOGGER.warning(Errors.format(ErrorKeys.VALUE_OUT_OF_BOUNDS_$3,
                            category, new Integer(0), new Integer(ARGB.length - 1)));
                }
                if (upper <= lower) {
                    continue;
                }
                final NumberRange target = getTargetRange(category, sampleDimension.getUnits());
                if (target != null) {
                    if (colors == null) {
                        colors = category.getColors();
                    }
                    if (colors.length >= 2) {
                        assert target.isMinIncluded() && !target.isMaxIncluded() : target;
                        final int lo = Math.max(lower, ((Number) target.getMinValue()).intValue());
                        final int hi = Math.min(upper, ((Number) target.getMaxValue()).intValue());
                        if (lo != lower || hi != upper) {
                            Arrays.fill(ARGB, lower, lo, colors[0].getRGB());
                            Arrays.fill(ARGB, hi, upper, colors[colors.length-1].getRGB());
                            lower = lo;
                            upper = hi;
                        }
                    }
                } else if (colors == null) {
                    /*
                     * If there is no range to change (target == null) and no colors explicitly
                     * specified by the user (colors == null), then there is nothing to do.
                     */
                    continue;
                }
                ColorUtilities.expand(colors, ARGB, lower, upper);
            } else if (colors == null) {
                continue;
            }
            category = category.recolor(colors);
            if (!categories[i].equals(category)) {
                categories[i] = category;
                changed = true;
            }
        }
        if (!changed) {
            return sampleDimension;
        }
        GridSampleDimension result = new GridSampleDimension(displayDimension.getDescription(),
                categories, displayDimension.getUnits());
        if (sampleDimension != displayDimension) {
            result = result.geophysics(true);
        }
        return result;
    }

    /**
     * Returns all category names declared in this color map, in alphabetical order.
     * If the {@link #ANY_QUANTITATIVE_CATEGORY} special value is presents, it will
     * appears last.
     */
    private CharSequence[] getCategoryNames() {
        final Set/*<String>*/ names;
        if (colorMap != null) {
            if (colorRanges != null) {
                names = new HashSet(colorMap.keySet());
                names.addAll(colorRanges.keySet());
            } else {
                names = colorMap.keySet();
            }
        } else {
            if (colorRanges != null) {
                names = colorRanges.keySet();
            } else {
                names = Collections.EMPTY_SET;
            }
        }
        int count = names.size();
        final CharSequence[] asArray = (CharSequence[]) names.toArray(new CharSequence[count]);
        for (int i=count; --i>=0;) {
            if (asArray[i] == null) {
                System.arraycopy(asArray, i+1, asArray, i, --count-i);
                asArray[count] = ANY_QUANTITATIVE_CATEGORY;
                // We could stop the loop here since we should not have any additional
                // null values. However we let the loop continue as a paranoiac check.
            }
        }
        Arrays.sort(asArray, 0, count);
        return asArray;
    }

    /**
     * Returns a hash code value for this color map.
     */
    //@Override
    public int hashCode() {
        return (int) serialVersionUID ^
              ((colorMap    != null ? colorMap   .hashCode() : 31) + 37*
               (colorRanges != null ? colorRanges.hashCode() : 31));
    }

    /**
     * Compares this color map with the specified object for equality.
     */
    //@Override
    public boolean equals(final Object object) {
        if (object!=null && getClass().equals(object.getClass())) {
            final ColorMap that = (ColorMap) object;
            return Utilities.equals(this.colorMap,    that.colorMap) &&
                   Utilities.equals(this.colorRanges, that.colorRanges);
        }
        return false;
    }

    /**
     * Returns a string representation of this color map.
     */
    //@Override
    public String toString() {
        final CharSequence[] names = getCategoryNames();
        final TableWriter writer = new TableWriter(null, 1);
        for (int i=0; i<names.length; i++) {
            final CharSequence name = names[i];
            writer.write(name.toString());
            if (colorRanges != null) {
                final NumberRange range = getRange(name);
                if (range != null) {
                    writer.write(' ');
                    writer.write(range.toString());
                    if (!(range instanceof MeasurementRange)) {
                        writer.write('%');
                    }
                }
            }
            writer.nextColumn();
            writer.write(':');
            writer.nextColumn();
            final Color[] colors = getColors(name);
            if (colors != null) {
                final String message;
                if (colors.length == 1) {
                    message = Integer.toHexString(colors[0].getRGB()).toUpperCase();
                } else {
                    message = Vocabulary.format(VocabularyKeys.COLOR_COUNT_$1,
                                new Integer(((Color[]) colors).length));
                }
                writer.write(message);
            }
            writer.nextLine();
        }
        return writer.toString();
    }
}
