/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.sis.geometry.wrapper.j2d;

import java.util.List;
import java.util.Arrays;
import java.util.ArrayList;
import java.awt.Shape;
import org.opengis.referencing.operation.TransformException;


/**
 * Builds a {@link Polyline}, {@link Polygon} or {@link MultiPolylines} from given coordinates.
 *
 * @author  Martin Desruisseaux (Geomatys)
 */
public class PathBuilder {
    /**
     * Number of coordinates in a tuple.
     */
    private static final int DIMENSION = 2;

    /**
     * The coordinates as (x,y) tuples. The number of valid coordinates is given by {@link #size}
     * and this array is expanded as needed. Shall not contains {@link Double#NaN} values.
     */
    private double[] coordinates;

    /**
     * Number of valid coordinates. This is twice the number of points.
     */
    private int size;

    /**
     * The polylines built from the coordinates.
     */
    private final List<Polyline> polylines;

    /**
     * Creates a new builder.
     */
    public PathBuilder() {
        coordinates = new double[100];
        polylines = new ArrayList<>();
    }

    /**
     * Verifies that {@link #size} is even, positive and smaller than the given limit.
     * This method is used for assertions.
     */
    private boolean isValidSize(final int limit) {
        return size >= 0 && size <= limit && (size & 1) == 0;
    }

    /**
     * Adds all polylines defined in the other builder. The other builder shall have no polylines under
     * construction, i.e. {@link #append(double[], int, boolean)} shall not have been invoked since last
     * {@link #createPolyline(boolean)} invocation.
     *
     * @param  other  the other builder for which to add polylines, or {@code null} if none.
     */
    public final void append(final PathBuilder other) {
        if (other != null) {
            assert other.size == 0;
            polylines.addAll(other.polylines);
        }
    }

    /**
     * Appends the given coordinates to current polyline, omitting repetitive points.
     * Coordinates are added to the same polyline as the one updated by previous calls
     * to this method, unless {@link #createPolyline(boolean)} has been invoked before.
     * The {@link #filterChunk(double[], int, int)} method is invoked after the points have been added
     * for allowing subclasses to apply customized filtering in addition to the above-cited removal
     * of repetitive points.
     *
     * <h4>NaN coordinate values</h4>
     * If the given array contains {@link Double#NaN} values, then the coordinates before and after NaNs are stored
     * in two distinct polylines. This is an exception to above paragraph saying that this method does not create
     * new polyline. The {@link #filterChunk(double[], int, int)} method will be invoked for each of those polylines.
     *
     * @param  source   coordinates to copy.
     * @param  limit    index after the last coordinate to copy. Must be an even number.
     * @param  reverse  whether to copy (x,y) tuples in reverse order.
     * @throws TransformException if {@link #filterFull(double[], int)} wanted to apply a coordinate operation
     *         and that transform failed.
     */
    public final void append(final double[] source, final int limit, final boolean reverse) throws TransformException {
        assert limit >= 0 && (limit & 1) == 0 : limit;
        int offset = size;
        if (limit >= coordinates.length - offset) {
            coordinates = Arrays.copyOf(coordinates, Math.addExact(offset, Math.max(offset, limit)));
        }
        final double[] coordinates = this.coordinates;
        double px, py;              // Previous point.
        if (offset != 0) {
            px = coordinates[offset - 2];
            py = coordinates[offset - 1];
        } else {
            px = py = Double.NaN;
        }
        for (int i=0; i<limit;) {
            final double x, y;
            if (reverse) {
                y = source[limit - ++i];
                x = source[limit - ++i];
            } else {
                x = source[i++];
                y = source[i++];
            }
            if (x != px || y != py) {
                if (Double.isNaN(x) || Double.isNaN(y)) {
                    if (offset != 0) {
                        size = filterChunk(coordinates, size, offset);
                        assert isValidSize(offset) : size;
                        createPolyline(false);
                        offset = 0;
                    }
                } else {
                    coordinates[offset++] = x;
                    coordinates[offset++] = y;
                }
                px = x;
                py = y;
            }
        }
        size = filterChunk(coordinates, size, offset);
        assert isValidSize(offset) : size;
    }

    /**
     * Applies a custom filtering on the coordinates added by a call to {@link #append(double[], int, boolean)}.
     * The default implementation does nothing. Subclasses can override this method for changing or removing some
     * coordinate values.
     *
     * <p>This method is invoked at least once per {@link #append(double[], int, boolean)} call.
     * Consequently, it is not necessarily invoked with the coordinates of a complete polyline or polygon,
     * because caller can build a polyline with multiple calls to {@code append(…)}.
     * If those {@code append(…)} calls correspond to some logical chunks (at users choice),
     * this {@code filterChunk(…)} method allows users to exploit this subdivision in their processing.</p>
     *
     * @param  coordinates  the coordinates to filter. Values can be modified in-place.
     * @param  lower        index of first coordinate to filter. Always even.
     * @param  upper        index after the last coordinate to filter. Always even.
     * @return number of valid coordinates after filtering.
     *         Should be {@code upper}, unless some coordinates have been removed.
     *         Must be an even number ≥ 0 and ≤ upper.
     */
    protected int filterChunk(double[] coordinates, int lower, int upper) {
        return upper;
    }

    /**
     * Applies a custom filtering on the coordinates of a polyline or polygon.
     * The default implementation does nothing. Subclasses can override this method for changing or removing some
     * coordinate values. For example, a subclass could decimate points using Ramer–Douglas–Peucker algorithm.
     * Contrarily to {@link #filterChunk(double[], int, int)}, this method is invoked when the coordinates of
     * the full polyline or polygon are available. If polyline points need to be transformed before to build
     * the final geometry, this is the right place to do so.
     *
     * @param  coordinates  the coordinates to filter. Values can be modified in-place.
     * @param  upper        index after the last coordinate to filter. Always even.
     * @return number of valid coordinates after filtering.
     *         Should be {@code upper}, unless some coordinates have been removed.
     *         Must be an even number ≥ 0 and ≤ upper.
     * @throws TransformException if this method wanted to apply a coordinate operation
     *         and that transform failed.
     */
    protected int filterFull(double[] coordinates, int upper) throws TransformException {
        return upper;
    }

    /**
     * Creates a new polyline or polygon with the coordinates added by {@link #append(double[], int, boolean)}.
     * If the first point and last point have the same coordinates, then the polyline is automatically closed as
     * a polygon. After this method call, next calls to {@code append(…)} will add coordinates in a new polyline.
     *
     * @param  close  whether to force a polygon even if source and last points are different.
     * @throws TransformException if {@link #filterFull(double[], int)} wanted to apply a coordinate operation
     *         and that transform failed.
     */
    public final void createPolyline(boolean close) throws TransformException {
        size = filterFull(coordinates, size);
        assert isValidSize(coordinates.length) : size;
        /*
         * If the point would be alone, discard the lonely point because it would be invisible
         * (a "move to" operation without "line to"). If there are two points, they should not
         * be equal because `append(…)` filtered repetitive points.
         */
        if (size >= 2*DIMENSION) {
            if (coordinates[0] == coordinates[size - 2] &&
                coordinates[1] == coordinates[size - 1])
            {
                size -= DIMENSION;
                close = true;
            }
            polylines.add(close ? new Polygon(coordinates, size) : new Polyline(coordinates, size));
        }
        size = 0;
    }

    /**
     * Returns a shape containing all polylines or polygons added to this builder.
     * The {@link #createPolyline(boolean)} method should be invoked before this method
     * for making sure that there are no pending polylines.
     *
     * @return the polyline, polygon or collection of polylines.
     *         May be {@code null} if no polyline or polygon has been created.
     */
    public final Shape build() {
        switch (polylines.size()) {
            case 0:  return null;
            case 1:  return polylines.get(0);
            default: return new MultiPolylines(polylines.toArray(Polyline[]::new));
        }
    }

    /**
     * Returns a snapshot of currently added polylines or polygons without modifying the state of this builder.
     * It is safe to continue building the shape and invoke this method again later for progressive rendering.
     *
     * @return the polyline, polygon or collection of polylines added so far.
     *         May be {@code null} if no polyline or polygon has been created.
     */
    public final Shape snapshot() {
        return build();
    }

    /**
     * Returns a string representation of the polyline under construction for debugging purposes.
     * Current implementation formats only the first and last points, and tells how many points are between.
     */
    @Override
    public String toString() {
        return toString(coordinates, size);
    }

    /**
     * Returns a string representation of the given coordinates for debugging purposes.
     * Current implementation formats only the first and last points, and tells how many
     * points are between.
     *
     * @param  coordinates  the coordinates for which to return a string representation.
     * @param  size         index after the last valid coordinate in {@code coordinates}.
     * @return a string representation for debugging purposes.
     */
    public static String toString(final double[] coordinates, final int size) {
        final StringBuilder b = new StringBuilder(30).append('[');
        if (size >= DIMENSION) {
            b.append((float) coordinates[0]).append(", ").append((float) coordinates[1]);
            final int n = size - DIMENSION;
            if (n >= DIMENSION) {
                b.append(", ");
                if (size >= DIMENSION*3) {
                    b.append(" … (").append(size / DIMENSION - 2).append(" pts) … ");
                }
                b.append((float) coordinates[n]).append(", ").append((float) coordinates[n+1]);
            }
        }
        return b.append(']').toString();
    }
}
