/*
 * Copyright (c) 2010, 2014, Oracle and/or its affiliates. All rights reserved.
 * ORACLE PROPRIETARY/CONFIDENTIAL. Use is subject to license terms.
 *
 *
 *
 *
 *
 *
 *
 *
 *
 *
 *
 *
 *
 *
 *
 *
 *
 *
 *
 *
 */

package javafx.scene.chart;

import java.util.ArrayList;
import java.util.List;

import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ObjectPropertyBase;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.value.WritableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.geometry.Dimension2D;
import javafx.geometry.Side;
import javafx.util.Duration;

import com.sun.javafx.charts.ChartLayoutAnimator;

import javafx.css.StyleableBooleanProperty;
import javafx.css.StyleableDoubleProperty;
import javafx.css.CssMetaData;

import com.sun.javafx.css.converters.BooleanConverter;
import com.sun.javafx.css.converters.SizeConverter;

import java.util.Collections;

import javafx.css.Styleable;
import javafx.css.StyleableProperty;

/**
 * A axis implementation that will works on string categories where each
 * value as a unique category(tick mark) along the axis.
 * @since JavaFX 2.0
 */
public final class CategoryAxis extends Axis<String> {

    // -------------- PRIVATE FIELDS -------------------------------------------
    private List<String> allDataCategories = new ArrayList<String>();
    private boolean changeIsLocal = false;
    /** This is the gap between one category and the next along this axis */
    private final DoubleProperty firstCategoryPos = new SimpleDoubleProperty(this, "firstCategoryPos", 0);
    private Object currentAnimationID;
    private final ChartLayoutAnimator animator = new ChartLayoutAnimator(this);
    private ListChangeListener<String> itemsListener = c -> {
        while (c.next()) {
            if(!c.getAddedSubList().isEmpty()) {
                // remove duplicates else they will get rendered on the chart.
                // Ideally we should be using a Set for categories.
                for (String addedStr : c.getAddedSubList())
                    checkAndRemoveDuplicates(addedStr);
                }
            if (!isAutoRanging()) {
                allDataCategories.clear();
                allDataCategories.addAll(getCategories());
                rangeValid = false;
            }
            requestAxisLayout();
        }
    };

    // -------------- PUBLIC PROPERTIES ----------------------------------------

    /** The margin between the axis start and the first tick-mark */
    private DoubleProperty startMargin = new StyleableDoubleProperty(5) {
        @Override protected void invalidated() {
            requestAxisLayout();
        }

        @Override public CssMetaData<CategoryAxis,Number> getCssMetaData() {
            return StyleableProperties.START_MARGIN;
        }

        @Override
        public Object getBean() {
            return CategoryAxis.this;
        }

        @Override
        public String getName() {
            return "startMargin";
        }
    };
    public final double getStartMargin() { return startMargin.getValue(); }
    public final void setStartMargin(double value) { startMargin.setValue(value); }
    public final DoubleProperty startMarginProperty() { return startMargin; }

    /** The margin between the last tick mark and the axis end */
    private DoubleProperty endMargin = new StyleableDoubleProperty(5) {
        @Override protected void invalidated() {
            requestAxisLayout();
        }


        @Override public CssMetaData<CategoryAxis,Number> getCssMetaData() {
            return StyleableProperties.END_MARGIN;
        }

        @Override
        public Object getBean() {
            return CategoryAxis.this;
        }

        @Override
        public String getName() {
            return "endMargin";
        }
    };
    public final double getEndMargin() { return endMargin.getValue(); }
    public final void setEndMargin(double value) { endMargin.setValue(value); }
    public final DoubleProperty endMarginProperty() { return endMargin; }

    /** If this is true then half the space between ticks is left at the start
     * and end
     */
    private BooleanProperty gapStartAndEnd = new StyleableBooleanProperty(true) {
        @Override protected void invalidated() {
            requestAxisLayout();
        }


        @Override public CssMetaData<CategoryAxis,Boolean> getCssMetaData() {
            return StyleableProperties.GAP_START_AND_END;
        }

        @Override
        public Object getBean() {
            return CategoryAxis.this;
        }

        @Override
        public String getName() {
            return "gapStartAndEnd";
        }
    };
    public final boolean isGapStartAndEnd() { return gapStartAndEnd.getValue(); }
    public final void setGapStartAndEnd(boolean value) { gapStartAndEnd.setValue(value); }
    public final BooleanProperty gapStartAndEndProperty() { return gapStartAndEnd; }

    private ObjectProperty<ObservableList<String>> categories = new ObjectPropertyBase<ObservableList<String>>() {
        ObservableList<String> old;
        @Override protected void invalidated() {
            if (getDuplicate() != null) {
                throw new IllegalArgumentException("Duplicate category added; "+getDuplicate()+" already present");
            }
            final ObservableList<String> newItems = get();
            if (old != newItems) {
                // Add and remove listeners
                if (old != null) old.removeListener(itemsListener);
                if (newItems != null) newItems.addListener(itemsListener);
                old = newItems;
            }
        }

        @Override
        public Object getBean() {
            return CategoryAxis.this;
        }

        @Override
        public String getName() {
            return "categories";
        }
    };

    /**
     * The ordered list of categories plotted on this axis. This is set automatically
     * based on the charts data if autoRanging is true. If the application sets the categories
     * then auto ranging is turned off. If there is an attempt to add duplicate entry into this list,
     * an {@link IllegalArgumentException} is thrown.
     */
    public final void setCategories(ObservableList<String> value) {
        categories.set(value);
        if (!changeIsLocal) {
            setAutoRanging(false);
            allDataCategories.clear();
            allDataCategories.addAll(getCategories());
        }
        requestAxisLayout();
    }

    private void checkAndRemoveDuplicates(String category) {
        if (getDuplicate() != null) {
            getCategories().remove(category);
            throw new IllegalArgumentException("Duplicate category ; "+category+" already present");
        }
    }

    private String getDuplicate() {
        if (getCategories() != null) {
            for (int i = 0; i < getCategories().size(); i++) {
                for (int j = 0; j < getCategories().size(); j++) {
                    if (getCategories().get(i).equals(getCategories().get(j)) && i != j) {
                        return getCategories().get(i);
                    }
                }
            }
        }
        return null;
    }
    /**
     * Returns a {@link ObservableList} of categories plotted on this axis.
     *
     * @return ObservableList of categories for this axis.
     * @see #categories
     */
    public final ObservableList<String> getCategories() {
        return categories.get();
    }

    /** This is the gap between one category and the next along this axis */
    private final ReadOnlyDoubleWrapper categorySpacing = new ReadOnlyDoubleWrapper(this, "categorySpacing", 1);
    public final double getCategorySpacing() {
        return categorySpacing.get();
    }
    public final ReadOnlyDoubleProperty categorySpacingProperty() {
        return categorySpacing.getReadOnlyProperty();
    }

    // -------------- CONSTRUCTORS -------------------------------------------------------------------------------------

    /**
     * Create a auto-ranging category axis with an empty list of categories.
     */
    public CategoryAxis() {
        changeIsLocal = true;
        setCategories(FXCollections.<String>observableArrayList());
        changeIsLocal = false;
    }

    /**
     * Create a category axis with the given categories. This will not auto-range but be fixed with the given categories.
     *
     * @param categories List of the categories for this axis
     */
    public CategoryAxis(ObservableList<String> categories) {
        setCategories(categories);
    }

    // -------------- PRIVATE METHODS ----------------------------------------------------------------------------------

    private double calculateNewSpacing(double length, List<String> categories) {
        final Side side = getEffectiveSide();
        double newCategorySpacing = 1;
        if(categories != null) {
            double bVal = (isGapStartAndEnd() ? (categories.size()) : (categories.size() - 1));
            // RT-14092 flickering  : check if bVal is 0
            newCategorySpacing = (bVal == 0) ? 1 : (length-getStartMargin()-getEndMargin()) / bVal;
        }
        // if autoranging is off setRange is not called so we update categorySpacing
        if (!isAutoRanging()) categorySpacing.set(newCategorySpacing);
        return newCategorySpacing;
    }

    private double calculateNewFirstPos(double length, double catSpacing) {
        final Side side = getEffectiveSide();
        double newPos = 1;
        double offset = ((isGapStartAndEnd()) ? (catSpacing / 2) : (0));
        if (side.isHorizontal()) {
            newPos = 0 + getStartMargin() + offset;
        }  else { // VERTICAL
            newPos = length - getStartMargin() - offset;
        }
        // if autoranging is off setRange is not called so we update first cateogory pos.
        if (!isAutoRanging()) firstCategoryPos.set(newPos);
        return newPos;
    }

    // -------------- PROTECTED METHODS --------------------------------------------------------------------------------

    /**
     * Called to get the current axis range.
     *
     * @return A range object that can be passed to setRange() and calculateTickValues()
     */
    @Override protected Object getRange() {
        return new Object[]{ getCategories(), categorySpacing.get(), firstCategoryPos.get(), getEffectiveTickLabelRotation() };
    }

    /**
     * Called to set the current axis range to the given range. If isAnimating() is true then this method should
     * animate the range to the new range.
     *
     * @param range A range object returned from autoRange()
     * @param animate If true animate the change in range
     */
    @Override protected void setRange(Object range, boolean animate) {
        Object[] rangeArray = (Object[]) range;
        @SuppressWarnings({"unchecked"}) List<String> categories = (List<String>)rangeArray[0];
//        if (categories.isEmpty()) new java.lang.Throwable().printStackTrace();
        double newCategorySpacing = (Double)rangeArray[1];
        double newFirstCategoryPos = (Double)rangeArray[2];
        setEffectiveTickLabelRotation((Double)rangeArray[3]);

        changeIsLocal = true;
        setCategories(FXCollections.<String>observableArrayList(categories));
        changeIsLocal = false;
        if (animate) {
            animator.stop(currentAnimationID);
            currentAnimationID = animator.animate(
                new KeyFrame(Duration.ZERO,
                    new KeyValue(firstCategoryPos, firstCategoryPos.get()),
                    new KeyValue(categorySpacing, categorySpacing.get())
                ),
                new KeyFrame(Duration.millis(1000),
                    new KeyValue(firstCategoryPos,newFirstCategoryPos),
                    new KeyValue(categorySpacing,newCategorySpacing)
                )
            );
        } else {
            categorySpacing.set(newCategorySpacing);
            firstCategoryPos.set(newFirstCategoryPos);
        }
    }

    /**
     * This calculates the categories based on the data provided to invalidateRange() method. This must not
     * effect the state of the axis, changing any properties of the axis. Any results of the auto-ranging should be
     * returned in the range object. This will we passed to setRange() if it has been decided to adopt this range for
     * this axis.
     *
     * @param length The length of the axis in screen coordinates
     * @return Range information, this is implementation dependent
     */
    @Override protected Object autoRange(double length) {
        final Side side = getEffectiveSide();
        // TODO check if we can display all categories
        final double newCategorySpacing = calculateNewSpacing(length,allDataCategories);
        final double newFirstPos = calculateNewFirstPos(length, newCategorySpacing);
        double tickLabelRotation = getTickLabelRotation();
        if (length >= 0) {
            double requiredLengthToDisplay = calculateRequiredSize(side.isVertical(), tickLabelRotation);
            if (requiredLengthToDisplay > length) {
                // try to rotate the text to increase the density
                if (side.isHorizontal() && tickLabelRotation != 90) {
                    tickLabelRotation = 90;
                }
                if (side.isVertical() && tickLabelRotation != 0) {
                    tickLabelRotation = 0;
                }
            }
        }
        return new Object[]{allDataCategories, newCategorySpacing, newFirstPos, tickLabelRotation};
    }

    private double calculateRequiredSize(boolean axisVertical, double tickLabelRotation) {
        // Calculate the max space required between categories labels
        double maxReqTickGap = 0;
        double last = 0;
        boolean first = true;
        for (String category: allDataCategories) {
            Dimension2D textSize = measureTickMarkSize(category, tickLabelRotation);
            double size = (axisVertical || (tickLabelRotation != 0)) ? textSize.getHeight() : textSize.getWidth();
            // TODO better handle calculations for rotated text, overlapping text etc
            if (first) {
                first = false;
                last = size/2;
            } else {
                maxReqTickGap = Math.max(maxReqTickGap, last + 6 + (size/2) );
            }
        }
        return getStartMargin() + maxReqTickGap*allDataCategories.size() + getEndMargin();
    }

    /**
     * Calculate a list of all the data values for each tick mark in range
     *
     * @param length The length of the axis in display units
     * @return A list of tick marks that fit along the axis if it was the given length
     */
    @Override protected List<String> calculateTickValues(double length, Object range) {
        Object[] rangeArray = (Object[]) range;
        //noinspection unchecked
        return (List<String>)rangeArray[0];
    }

    /**
     * Get the string label name for a tick mark with the given value
     *
     * @param value The value to format into a tick label string
     * @return A formatted string for the given value
     */
    @Override protected String getTickMarkLabel(String value) {
        // TODO use formatter
        return value;
    }

    /**
     * Measure the size of the label for given tick mark value. This uses the font that is set for the tick marks
     *
     * @param value tick mark value
     * @param range range to use during calculations
     * @return size of tick mark label for given value
     */
    @Override protected Dimension2D measureTickMarkSize(String value, Object range) {
        final Object[] rangeArray = (Object[]) range;
        final double tickLabelRotation = (Double)rangeArray[3];
        return measureTickMarkSize(value,tickLabelRotation);
    }

    // -------------- METHODS ------------------------------------------------------------------------------------------

    /**
     * Called when data has changed and the range may not be valid any more. This is only called by the chart if
     * isAutoRanging() returns true. If we are auto ranging it will cause layout to be requested and auto ranging to
     * happen on next layout pass.
     *
     * @param data The current set of all data that needs to be plotted on this axis
     */
    @Override public void invalidateRange(List<String> data) {
        super.invalidateRange(data);
        // Create unique set of category names
        List<String> categoryNames = new ArrayList<String>();
        categoryNames.addAll(allDataCategories);
        //RT-21141 allDataCategories needs to be updated based on data -
        // and should maintain the order it originally had for the categories already present.
        // and remove categories not present in data
        for(String cat : allDataCategories) {
            if (!data.contains(cat)) categoryNames.remove(cat);
        }
        // add any new category found in data
//        for(String cat : data) {
        for (int i = 0; i < data.size(); i++) {
           int len = categoryNames.size();
           if (!categoryNames.contains(data.get(i))) categoryNames.add((i > len) ? len : i, data.get(i));
        }
        allDataCategories.clear();
        allDataCategories.addAll(categoryNames);
    }

    final List<String> getAllDataCategories() {
        return allDataCategories;
    }

    /**
     * Get the display position along this axis for a given value.
     *
     * If the value is not equal to any of the categories, Double.NaN is returned
     *
     * @param value The data value to work out display position for
     * @return display position or Double.NaN if value not one of the categories
     */
    @Override public double getDisplayPosition(String value) {
        // find index of value
        final ObservableList<String> cat = getCategories();
        if (!cat.contains(value)) {
            return Double.NaN;
        }
        if (getEffectiveSide().isHorizontal()) {
            return firstCategoryPos.get() + cat.indexOf(value) * categorySpacing.get();
        } else {
            return firstCategoryPos.get() + cat.indexOf(value) * categorySpacing.get() * -1;
        }
    }

    /**
     * Get the data value for the given display position on this axis. If the axis
     * is a CategoryAxis this will be the nearest value.
     *
     * @param  displayPosition A pixel position on this axis
     * @return the nearest data value to the given pixel position or
     *         null if not on axis;
     */
    @Override public String getValueForDisplay(double displayPosition) {
        if (getEffectiveSide().isHorizontal()) {
            if (displayPosition < 0 || displayPosition > getWidth()) return null;
            double d = (displayPosition - firstCategoryPos.get()) /   categorySpacing.get();
            return toRealValue(d);
        } else { // VERTICAL
            if (displayPosition < 0 || displayPosition > getHeight()) return null;
            double d = (displayPosition - firstCategoryPos.get()) /   (categorySpacing.get() * -1);
            return toRealValue(d);
        }
    }

    /**
     * Checks if the given value is plottable on this axis
     *
     * @param value The value to check if its on axis
     * @return true if the given value is plottable on this axis
     */
    @Override public boolean isValueOnAxis(String value) {
        return getCategories().indexOf("" + value) != -1;
    }

    /**
     * All axis values must be representable by some numeric value. This gets the numeric value for a given data value.
     *
     * @param value The data value to convert
     * @return Numeric value for the given data value
     */
    @Override public double toNumericValue(String value) {
        return getCategories().indexOf(value);
    }

    /**
     * All axis values must be representable by some numeric value. This gets the data value for a given numeric value.
     *
     * @param value The numeric value to convert
     * @return Data value for given numeric value
     */
    @Override public String toRealValue(double value) {
        int index = (int)Math.round(value);
        List<String> categories = getCategories();
        if (index >= 0 && index < categories.size()) {
            return getCategories().get(index);
        } else {
            return null;
        }
    }

    /**
     * Get the display position of the zero line along this axis. As there is no concept of zero on a CategoryAxis
     * this is always Double.NaN.
     *
     * @return always Double.NaN for CategoryAxis
     */
    @Override public double getZeroPosition() {
        return Double.NaN;
    }

    // -------------- STYLESHEET HANDLING ------------------------------------------------------------------------------

    /** @treatAsPrivate implementation detail */
    private static class StyleableProperties {
        private static final CssMetaData<CategoryAxis,Number> START_MARGIN =
            new CssMetaData<CategoryAxis,Number>("-fx-start-margin",
                SizeConverter.getInstance(), 5.0) {

            @Override
            public boolean isSettable(CategoryAxis n) {
                return n.startMargin == null || !n.startMargin.isBound();
            }

            @Override
            public StyleableProperty<Number> getStyleableProperty(CategoryAxis n) {
                return (StyleableProperty<Number>)(WritableValue<Number>)n.startMarginProperty();
            }
        };

        private static final CssMetaData<CategoryAxis,Number> END_MARGIN =
            new CssMetaData<CategoryAxis,Number>("-fx-end-margin",
                SizeConverter.getInstance(), 5.0) {

            @Override
            public boolean isSettable(CategoryAxis n) {
                return n.endMargin == null || !n.endMargin.isBound();
            }

            @Override
            public StyleableProperty<Number> getStyleableProperty(CategoryAxis n) {
                return (StyleableProperty<Number>)(WritableValue<Number>)n.endMarginProperty();
            }
        };

        private static final CssMetaData<CategoryAxis,Boolean> GAP_START_AND_END =
            new CssMetaData<CategoryAxis,Boolean>("-fx-gap-start-and-end",
                BooleanConverter.getInstance(), Boolean.TRUE) {

            @Override
            public boolean isSettable(CategoryAxis n) {
                return n.gapStartAndEnd == null || !n.gapStartAndEnd.isBound();
            }

            @Override
            public StyleableProperty<Boolean> getStyleableProperty(CategoryAxis n) {
                return (StyleableProperty<Boolean>)(WritableValue<Boolean>)n.gapStartAndEndProperty();
            }
        };

        private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
        static {
        final List<CssMetaData<? extends Styleable, ?>> styleables =
            new ArrayList<CssMetaData<? extends Styleable, ?>>(Axis.getClassCssMetaData());
            styleables.add(START_MARGIN);
            styleables.add(END_MARGIN);
            styleables.add(GAP_START_AND_END);
            STYLEABLES = Collections.unmodifiableList(styleables);
        }
    }

    /**
     * @return The CssMetaData associated with this class, which may include the
     * CssMetaData of its super classes.
     * @since JavaFX 8.0
     */
    public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
        return StyleableProperties.STYLEABLES;
    }

    /**
     * {@inheritDoc}
     * @since JavaFX 8.0
     */
    @Override
    public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
        return getClassCssMetaData();
    }

}

