/*
 * Copyright (c) 2016 Vivid Solutions.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * and Eclipse Distribution License v. 1.0 which accompanies this distribution.
 * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html
 * and the Eclipse Distribution License is available at
 *
 * http://www.eclipse.org/org/documents/edl-v10.php.
 */
package org.locationtech.jtslab.geom.util;

import java.util.ArrayList;

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryCollection;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.LinearRing;
import org.locationtech.jts.geom.MultiLineString;
import org.locationtech.jts.geom.MultiPoint;
import org.locationtech.jts.geom.MultiPolygon;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.geom.util.GeometryTransformer;
import org.locationtech.jts.util.Assert;


/**
 * A class which supports creating new {@link Geometry}s 
 * which are modifications of existing ones,
 * maintaining the same type structure.
 * Geometry objects are intended to be treated as immutable.
 * This class "modifies" Geometrys
 * by traversing them, applying a user-defined
 * {@link GeometryEditorOperation}, {@link CoordinateSequenceOperation} or {@link CoordinateOperation}  
 * and creating new Geometrys with the same structure but
 * (possibly) modified components.
 * <p>
 * Examples of the kinds of modifications which can be made are:
 * <ul>
 * <li>the values of the coordinates may be changed.
 *     The editor does not check whether changing coordinate values makes the result Geometry invalid
 * <li>the coordinate lists may be changed
 *     (e.g. by adding, deleting or modifying coordinates).
 *     The modifed coordinate lists must be consistent with their original parent component
 *     (e.g. a <tt>LinearRing</tt> must always have at least 4 coordinates, and the first and last
 *     coordinate must be equal)
 * <li>components of the original geometry may be deleted
 *    (e.g. holes may be removed from a Polygon, or LineStrings removed from a MultiLineString).
 *     Deletions will be propagated up the component tree appropriately.
 * </ul>
 * All changes must be consistent with the original Geometry's structure
 * (e.g. a <tt>Polygon</tt> cannot be collapsed into a <tt>LineString</tt>).
 * If changing the structure is required, use a {@link GeometryTransformer}.
 * <p>
 * This class supports creating an edited Geometry
 * using a different <code>GeometryFactory</code> via the {@link #GeometryEditor(GeometryFactory)}
 * constructor.  
 * Examples of situations where this is required is if the geometry is 
 * transformed to a new SRID and/or a new PrecisionModel.
 * <p>
 * <b>Usage Notes</b>
 * <ul>
 * <li>The resulting Geometry is not checked for validity.
 * If validity needs to be enforced, the new Geometry's 
 * {@link Geometry#isValid} method should be called.
 * <li>By default the UserData of the input geometry is not copied to the result.
 * </ul>
 * 
 * @see GeometryTransformer
 * @see Geometry#isValid
 *
 * @version 1.7
 */
public class GeometryEditorEx
{
  /**
   * The factory used to create the modified Geometry.
   * If <tt>null</tt> the GeometryFactory of the input is used.
   */
  private GeometryFactory targetFactory = null;
  private boolean isUserDataCopied = false;
  private GeometryEditorOperation operation;

  /**
   * Creates a new GeometryEditor object which will create
   * edited {@link Geometry}s with the same {@link GeometryFactory} as the input Geometry.
   */
  public GeometryEditorEx()
  {
    this(new NoOpGeometryOperation());
  }

  /**
   * Creates a new GeometryEditor object which will create
   * edited {@link Geometry}s with the given {@link GeometryFactory}.
   *
   * @param targetFactory the GeometryFactory to create  edited Geometrys with
   */
  public GeometryEditorEx(GeometryFactory targetFactory)
  {
    this(new NoOpGeometryOperation(), targetFactory);
  }

  /**
   * Creates a GeometryEditor which edits geometries using
   * a given {@link GeometryOperation}
   * and the same {@link GeometryFactory} as the input Geometry.
   * 
   * @param operation the edit operation to use
   */
  public GeometryEditorEx(GeometryEditorOperation operation)
  {
    this.operation = operation;
  }

  /**
   * Creates a GeometryEditor which edits geometries using
   * a given {@link GeometryOperation}
   * and the given {@link GeometryFactory}.
   * 
   * @param operation the edit operation to use
   * @param targetFactory the GeometryFactory to create  edited Geometrys with
   * 
   */
  public GeometryEditorEx(GeometryEditorOperation operation, GeometryFactory targetFactory)
  {
    this.operation = operation;
    this.targetFactory = targetFactory;
  }


  /**
   * Sets whether the User Data is copied to the edit result.
   * Only the object reference is copied.
   * 
   * @param isUserDataCopied true if the input user data should be copied.
   */
  public void setCopyUserData(boolean isUserDataCopied)
  {
    this.isUserDataCopied = isUserDataCopied;
  }
  
  /**
   * Edit a {@link Geometry}.
   * Clients can create subclasses of {@link GeometryEditorOperation} or
   * {@link CoordinateOperation} to perform required modifications.
   *
   * @param geometry the Geometry to edit
   * @return a new {@link Geometry} which is the result of the editing (which may be empty)
   */
  public Geometry edit(Geometry geometry)
  {
    // nothing to do
    if (geometry == null) return null;
    
    Geometry result = editInternal(geometry);
    if (isUserDataCopied) {
      result.setUserData(geometry.getUserData());
    }
    return result;
  }
  
  private Geometry editInternal(Geometry geometry)
  {
    // if client did not supply a GeometryFactory, use the one from the input Geometry
    if (targetFactory == null)
      targetFactory = geometry.getFactory();

    if (geometry instanceof GeometryCollection) {
      return editGeometryCollection((GeometryCollection) geometry);
    }
    if (geometry instanceof Polygon) {
      return editPolygon((Polygon) geometry);
    }
    if (geometry instanceof Point) {
      return editPoint((Point) geometry);
    }
    if (geometry instanceof LinearRing) {
      return editLinearRing((LinearRing) geometry);
    }
    if (geometry instanceof LineString) {
      return editLineString((LineString) geometry);
    }

    Assert.shouldNeverReachHere("Unsupported Geometry class: " + geometry.getClass().getName());
    return null;
  }

  private Point editPoint(Point geom) {
    Point newGeom = (Point) operation.edit(geom, targetFactory);
    if (newGeom == null) {
      // null return means create an empty one
      newGeom = targetFactory.createPoint((CoordinateSequence) null);
    }
    else if (newGeom == geom) {
     // If geometry was not modified, copy it
      newGeom = (Point) targetFactory.createGeometry(geom);
    }
    return newGeom;
  }
  
  private LineString editLineString(LineString geom) {
    LineString newGeom = (LineString) operation.edit(geom, targetFactory);
    if (newGeom == null) {
      // null return means create an empty one
      newGeom = targetFactory.createLineString((CoordinateSequence) null);
    }
    else if (newGeom == geom) {
     // If geometry was not modified, copy it
      newGeom = (LineString) targetFactory.createGeometry(geom);
    }
    return newGeom;
  }
  
  private LinearRing editLinearRing(LinearRing geom) {
    LinearRing newGeom = (LinearRing) operation.edit(geom, targetFactory);
    if (newGeom == null) {
      // null return means create an empty one
      newGeom = targetFactory.createLinearRing((CoordinateSequence) null);
    }
    else if (newGeom == geom) {
     // If geometry was not modified, copy it
      newGeom = (LinearRing) targetFactory.createGeometry(geom);
    }
    return newGeom;
  }
  
  private Polygon editPolygon(Polygon polygon) {
    Polygon newPolygon = (Polygon) operation.edit(polygon, targetFactory);
    // create one if needed
    if (newPolygon == null) {
      newPolygon = targetFactory.createPolygon((CoordinateSequence) null);
      return newPolygon;
    }
    /**
     * If geometry was modified, return it
     */
    if (newPolygon != polygon) {
      return newPolygon;
    }

    LinearRing shell = (LinearRing) edit(newPolygon.getExteriorRing());
    if (shell == null || shell.isEmpty()) {
      return targetFactory.createPolygon(null, null);
    }

    ArrayList holes = new ArrayList();
    for (int i = 0; i < newPolygon.getNumInteriorRing(); i++) {
      LinearRing hole = (LinearRing) edit(newPolygon.getInteriorRingN(i));
      if (hole == null || hole.isEmpty()) {
        continue;
      }
      holes.add(hole);
    }

    return targetFactory.createPolygon(shell,
                                 (LinearRing[]) holes.toArray(new LinearRing[] {  }));
  }

  private GeometryCollection editGeometryCollection(
      GeometryCollection collection) {
    // first edit the entire collection
    // MD - not sure why this is done - could just check original collection?
    GeometryCollection collectionForType = (GeometryCollection) operation.edit(collection,
        targetFactory);
    
    if (collectionForType != collection) {
      return collectionForType;
    }
    
    // edit the component geometries
    ArrayList geometries = new ArrayList();
    for (int i = 0; i < collectionForType.getNumGeometries(); i++) {
      Geometry geometry = edit(collectionForType.getGeometryN(i));
      if (geometry == null || geometry.isEmpty()) {
        continue;
      }
      geometries.add(geometry);
    }

    if (collectionForType.getClass() == MultiPoint.class) {
      return targetFactory.createMultiPoint((Point[]) geometries.toArray(
            new Point[] {  }));
    }
    if (collectionForType.getClass() == MultiLineString.class) {
      return targetFactory.createMultiLineString((LineString[]) geometries.toArray(
            new LineString[] {  }));
    }
    if (collectionForType.getClass() == MultiPolygon.class) {
      return targetFactory.createMultiPolygon((Polygon[]) geometries.toArray(
            new Polygon[] {  }));
    }
    return targetFactory.createGeometryCollection((Geometry[]) geometries.toArray(
          new Geometry[] {  }));
  }

  /**
   * A interface which specifies an edit operation for Geometries.
   *
   * @version 1.7
   */
  public interface GeometryEditorOperation
  {
    /**
     * Edits a Geometry by returning a new Geometry with a modification.
     * The returned geometry may be:
     * <ul>
     * <li>the input geometry itself
     * The returned Geometry might be the same as the Geometry passed in.
     * It may be <code>null</code> if the geometry is to be deleted.
     *
     * @param geometry the Geometry to modify
     * @param factory the factory with which to construct the modified Geometry
     * (may be different to the factory of the input geometry)
     * @return a new Geometry which is a modification of the input Geometry
     * @return null if the Geometry is to be deleted completely
     */
    Geometry edit(Geometry geometry,GeometryFactory targetFactory);
  }

  /**
   * A GeometryEditorOperation which does not modify
   * the input geometry.
   * This can be used for simple changes of 
   * GeometryFactory (including PrecisionModel and SRID).
   * 
   * @author mbdavis
   *
   */
  public static class NoOpGeometryOperation
  implements GeometryEditorOperation
  {
  	public Geometry edit(Geometry geometry, GeometryFactory targetFactory)
  	{
  		return geometry;
  	}
  }
  
  /**
   * A {@link GeometryEditorOperation} which edits the coordinate list of a {@link Geometry}.
   * Operates on Geometry subclasses which contains a single coordinate list.
   */
  public abstract static class CoordinateOperation
      implements GeometryEditorOperation
  {
    public final Geometry edit(Geometry geometry, GeometryFactory targetFactory) {
      if (geometry instanceof LinearRing) {
        return targetFactory.createLinearRing(edit(geometry.getCoordinates(),
            geometry));
      }

      if (geometry instanceof LineString) {
        return targetFactory.createLineString(edit(geometry.getCoordinates(),
            geometry));
      }

      if (geometry instanceof Point) {
        Coordinate[] newCoordinates = edit(geometry.getCoordinates(),
            geometry);

        return targetFactory.createPoint((newCoordinates.length > 0)
                                   ? newCoordinates[0] : null);
      }

      return geometry;
    }

    /**
     * Edits the array of {@link Coordinate}s from a {@link Geometry}.
     * <p>
     * If it is desired to preserve the immutability of Geometrys,
     * if the coordinates are changed a new array should be created
     * and returned.
     *
     * @param coordinates the coordinate array to operate on
     * @param geometry the geometry containing the coordinate list
     * @return an edited coordinate array (which may be the same as the input)
     */
    public abstract Coordinate[] edit(Coordinate[] coordinates,
                                      Geometry geometry);
  }
  
  /**
   * A {@link GeometryEditorOperation} which edits the {@link CoordinateSequence}
   * of a {@link Geometry}.
   * Operates on Geometry subclasses which contains a single coordinate list.
   */
  public abstract static class CoordinateSequenceOperation
      implements GeometryEditorOperation
  {
    public final Geometry edit(Geometry geometry, GeometryFactory targetFactory) {
      if (geometry instanceof LinearRing) {
        return targetFactory.createLinearRing(edit(
            ((LinearRing)geometry).getCoordinateSequence(),
            geometry, targetFactory));
      }

      if (geometry instanceof LineString) {
        return targetFactory.createLineString(edit(
            ((LineString)geometry).getCoordinateSequence(),
            geometry, targetFactory));
      }

      if (geometry instanceof Point) {
        return targetFactory.createPoint(edit(
            ((Point)geometry).getCoordinateSequence(),
            geometry, targetFactory));
      }

      return geometry;
    }

    /**
     * Edits a {@link CoordinateSequence} from a {@link Geometry}.
     *
     * @param coordseq the coordinate array to operate on
     * @param geometry the geometry containing the coordinate list
     * @return an edited coordinate sequence (which may be the same as the input)
     */
    public abstract CoordinateSequence edit(CoordinateSequence coordSeq,
                                      Geometry geometry, GeometryFactory targetFactory);
  }
}
