/*
 * Copyright (c) 2022 Martin Davis.
 *
 * 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.jts.coverage;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateArrays;
import org.locationtech.jts.geom.CoordinateList;
import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineSegment;
import org.locationtech.jts.geom.LinearRing;
import org.locationtech.jts.geom.MultiPolygon;
import org.locationtech.jts.geom.Polygon;

/**
 * Models a polygonal coverage as a set of unique {@link CoverageEdge}s,
 * linked to the parent rings in the coverage polygons.
 * Each edge has either one or two parent rings, depending on whether 
 * it is an inner or outer edge of the coverage.
 * The source coverage is represented as a array of polygonal geometries 
 * (either {@link Polygon}s or {@link MultiPolygon}s).
 * <p>
 * Coverage edges are found by identifying vertices which are nodes in the coverage,
 * splitting edges at nodes, and then identifying unique edges.
 * The unique edges are associated to their parent ring (in order),
 * to allow reforming the coverage polygons.
 * 
 * @author Martin Davis
 *
 */
class CoverageRingEdges {
  
  /**
   * Create a new instance for a given coverage.
   * 
   * @param coverage the set of polygonal geometries in the coverage
   * @return the edges of the coverage
   */
  public static CoverageRingEdges create(Geometry[] coverage) {
    CoverageRingEdges edges = new CoverageRingEdges(coverage);
    return edges;
  }
  
  private Geometry[] coverage;
  private Map<LinearRing, List<CoverageEdge>> ringEdgesMap;
  private List<CoverageEdge> edges;
  
  public CoverageRingEdges(Geometry[] coverage) {
    this.coverage = coverage;
    ringEdgesMap = new HashMap<LinearRing, List<CoverageEdge>>();
    edges = new ArrayList<CoverageEdge>();
    build();
  }

  public List<CoverageEdge> getEdges() {
    return edges;
  }
  
  private void build() {
    Set<Coordinate> nodes = findMultiRingNodes(coverage);
    Set<LineSegment> boundarySegs = CoverageBoundarySegmentFinder.findBoundarySegments(coverage);
    nodes.addAll(findBoundaryNodes(boundarySegs));
    HashMap<LineSegment, CoverageEdge> uniqueEdgeMap = new HashMap<LineSegment, CoverageEdge>();
    for (int i = 0; i < coverage.length; i++) {
      //-- geom is a Polygon or MultiPolygon
      Geometry geom = coverage[i];
      int indexLargest = findLargestPolygonIndex(geom);
      for (int ipoly = 0; ipoly < geom.getNumGeometries(); ipoly++) {
        Polygon poly = (Polygon) geom.getGeometryN(ipoly);
        
        //-- skip empty elements. Missing elements are copied in result
        if (poly.isEmpty())
          continue;
        
        //-- largest polygon is the primary one, which is never removed
        boolean isPrimary = ipoly == indexLargest;
        
        //-- extract shell
        LinearRing shell = poly.getExteriorRing();
        addRingEdges(i, shell, isPrimary, nodes, boundarySegs, uniqueEdgeMap);
        //-- extract holes
        for (int ihole = 0; ihole < poly.getNumInteriorRing(); ihole++) {
          LinearRing hole = poly.getInteriorRingN(ihole);
          //-- skip empty holes. Missing rings are copied in result
          if (hole.isEmpty())
            continue;
          //-- holes are never primary
          addRingEdges(i, hole, false, nodes, boundarySegs, uniqueEdgeMap);         
        }
      }
    }
  }

  private int findLargestPolygonIndex(Geometry geom) {
    if (geom instanceof Polygon)
      return 0;
    int indexLargest = -1;
    double areaLargest = -1;
    for (int ipoly = 0; ipoly < geom.getNumGeometries(); ipoly++) {
      Polygon poly = (Polygon) geom.getGeometryN(ipoly);
      double area = poly.getArea();
      if (area > areaLargest) {
        areaLargest = area;
        indexLargest = ipoly;
      }
    }
    return indexLargest;
  }

  private void addRingEdges(int index, LinearRing ring, boolean isPrimary, Set<Coordinate> nodes, Set<LineSegment> boundarySegs,
      HashMap<LineSegment, CoverageEdge> uniqueEdgeMap) {
    addBoundaryInnerNodes(ring, boundarySegs, nodes);
    List<CoverageEdge> ringEdges = extractRingEdges(index, ring, isPrimary, uniqueEdgeMap, nodes);
    if (ringEdges != null)
      ringEdgesMap.put(ring, ringEdges);
  }

  /**
   * Detects nodes occurring at vertices which are between a boundary segment 
   * and an inner (shared) segment.  
   * These occur where two polygons are adjacent at the coverage boundary
   * (this is not detected by {@link #findMultiRingNodes(Geometry[])}).
   * 
   * @param ring
   * @param boundarySegs
   * @param nodes
   */
  private void addBoundaryInnerNodes(LinearRing ring, Set<LineSegment> boundarySegs, Set<Coordinate> nodes) {
    CoordinateSequence seq = ring.getCoordinateSequence();
    boolean isBdyLast = CoverageBoundarySegmentFinder.isBoundarySegment(boundarySegs, seq, seq.size() - 2);
    boolean isBdyPrev = isBdyLast;
    for (int i = 0; i < seq.size() - 1; i++) {
      boolean isBdy = CoverageBoundarySegmentFinder.isBoundarySegment(boundarySegs, seq, i);
      if (isBdy != isBdyPrev) {
        Coordinate nodePt = seq.getCoordinate(i);
        nodes.add(nodePt);
      }
      isBdyPrev = isBdy;
    }
  }

  /**
   * Extracts the {@link CoverageEdge}s for a ring.
   * @param index 
   * 
   * @param ring
   * @param isRetained true if the ring is retained (must not be removed)
   * @param uniqueEdgeMap
   * @param nodes
   * @return null if the ring has too few distinct vertices
   */
  private List<CoverageEdge> extractRingEdges(int index, LinearRing ring, 
      boolean isPrimary, HashMap<LineSegment, CoverageEdge> uniqueEdgeMap, 
      Set<Coordinate> nodes) {
 // System.out.println(ring);
    List<CoverageEdge> ringEdges = new ArrayList<CoverageEdge>();
    
    Coordinate[] pts = ring.getCoordinates();
    pts = CoordinateArrays.removeRepeatedPoints(pts);
    //-- if compacted ring is too short, don't process it
    if (pts.length < 3)
      return null;
    
    int first = findNextNodeIndex(pts, -1, nodes);
    if (first < 0) {
      //-- ring does not contain a node, so edge is entire ring
      CoverageEdge edge = createEdge(pts, -1, -1, index, isPrimary, uniqueEdgeMap);
      ringEdges.add(edge);
    }
    else {
      int start = first;
      int end = start;
      //-- two-node edges are always primary
      boolean isEdgePrimary = true;
      do {
        end = findNextNodeIndex(pts, start, nodes);
        //-- a single-node ring is only retained if specified
        if (end == start) {
          isEdgePrimary = isPrimary;
        }
        CoverageEdge edge = createEdge(pts, start, end, index, isEdgePrimary, uniqueEdgeMap);
//  System.out.println(ringEdges.size() + " : " + edge);
        ringEdges.add(edge);
        start = end;
      } while (end != first);
    }
    return ringEdges;
  }
  
  /**
   * Creates or updates an edge for the given ring or ring section.
   * 
   * @param ring ring to create edge for
   * @param start start index of ring section; -1 indicates edge is entire ring
   * @param end end index of ring section
   * @param index 
   * @param isPrimary whether this ring is a primary ring
   * @param uniqueEdgeMap map of edges
   * @return the CoverageEdge for the ring or portion of ring
   */
  private CoverageEdge createEdge(Coordinate[] ring, int start, int end, int index, boolean isPrimary, HashMap<LineSegment, CoverageEdge> uniqueEdgeMap) {
    CoverageEdge edge;
    LineSegment edgeKey = (end == start) ? CoverageEdge.key(ring) : CoverageEdge.key(ring, start, end);
    if (uniqueEdgeMap.containsKey(edgeKey)) {
      edge = uniqueEdgeMap.get(edgeKey);
      //-- update shared attributes
      edge.setPrimary(isPrimary);
    }
    else {
      if (start < 0) {
        edge = CoverageEdge.createEdge(ring, isPrimary);
      }
      else {
        edge = CoverageEdge.createEdge(ring, start, end, isPrimary);
      }
      uniqueEdgeMap.put(edgeKey, edge);
      edges.add(edge);
    }
    edge.addIndex(index);
    edge.incRingCount();
    return edge;
  }

  private int findNextNodeIndex(Coordinate[] ring, int start, Set<Coordinate> nodes) {
    int index = start;
    boolean isScanned0 = false;
    do {
      index = next(index, ring);
      if (index == 0) {
        if (start < 0 && isScanned0) 
          return -1;
        isScanned0 = true;
      }
      Coordinate pt = ring[index];
      if (nodes.contains(pt)) {
        return index;
      }
    } while (index != start);
    return -1;
  }

  private static int next(int index, Coordinate[] ring) {
    index = index + 1;
    if (index >= ring.length - 1)
      index = 0;
    return index;
  }

  /**
   * Finds nodes in a coverage at vertices which are shared by 3 or more rings.
   * 
   * @param coverage a list of polygonal geometries
   * @return the set of nodes contained in 3 or more rings
   */
  private Set<Coordinate> findMultiRingNodes(Geometry[] coverage) {
    Map<Coordinate, Integer> vertexRingCount = VertexRingCounter.count(coverage);
    Set<Coordinate> nodes = new HashSet<Coordinate>();
    for (Coordinate v : vertexRingCount.keySet()) {
      if (vertexRingCount.get(v) >= 3) {
        nodes.add(v);
      }
    }
    return nodes;
  }

  /**
   * Finds nodes occurring between boundary segments.
   * Nodes on boundaries occur at vertices which have 
   * 3 or more incident boundary segments.
   * This detects situations where two rings touch only at a vertex
   * (i.e. two polygons touch, or a polygon shell touches a hole)
   * These nodes lie in only 2 adjacent rings, 
   * so are not detected by {@link #findMultiRingNodes(Geometry[])}. 
   * 
   * @param boundarySegments
   * @return a set of vertices which are nodes where two rings touch
   */
  private Set<Coordinate> findBoundaryNodes(Set<LineSegment> boundarySegments) {
    Map<Coordinate, Integer> counter = new HashMap<>();
    for (LineSegment seg : boundarySegments) {
      counter.put(seg.p0, counter.getOrDefault(seg.p0, 0) + 1);
      counter.put(seg.p1, counter.getOrDefault(seg.p1, 0) + 1);
    }
    return counter.entrySet().stream()
        .filter(e->e.getValue() > 2)
        .map(Map.Entry::getKey).collect(Collectors.toSet());
  }

  /**
   * Recreates the polygon coverage from the current edge values.
   * 
   * @return an array of polygonal geometries representing the coverage
   */
  public Geometry[] buildCoverage() {
    Geometry[] result = new Geometry[coverage.length];
    for (int i = 0; i < coverage.length; i++) {
      result[i] = buildPolygonal(coverage[i]);
    }
    return result;
  }

  private Geometry buildPolygonal(Geometry geom) {
    if (geom instanceof MultiPolygon) {
      return buildMultiPolygon((MultiPolygon) geom);
    }
    else {
      return buildPolygon((Polygon) geom);
    }
  }

  private Geometry buildMultiPolygon(MultiPolygon geom) {
    List<Polygon> polyList = new ArrayList<Polygon>();
    for (int i = 0; i < geom.getNumGeometries(); i++) {
       Polygon poly = buildPolygon((Polygon) geom.getGeometryN(i));
       if (poly != null) {
         polyList.add(poly);
       }
    }
    if (polyList.size() == 1) {
      return polyList.get(0);
    }
    Polygon[] polys = GeometryFactory.toPolygonArray(polyList);
    return geom.getFactory().createMultiPolygon(polys);
  }

  /**
   * 
   * @param polygon
   * @return null if the polygon has been removed
   */
  private Polygon buildPolygon(Polygon polygon) {
    LinearRing shell = buildRing(polygon.getExteriorRing());
    if (shell == null) {
      return null;
    }
    if (polygon.getNumInteriorRing() == 0) {
      return polygon.getFactory().createPolygon(shell);
    }
    List<LinearRing> holeList = new ArrayList<LinearRing>();
    for (int i = 0; i < polygon.getNumInteriorRing(); i++) {
      LinearRing hole = polygon.getInteriorRingN(i);
      LinearRing newHole = buildRing(hole);
      if (newHole != null) {
        holeList.add(newHole);
      }
    }
    //LinearRing holes[] = new LinearRing[polygon.getNumInteriorRing()];
    LinearRing holes[] = GeometryFactory.toLinearRingArray(holeList);
    return polygon.getFactory().createPolygon(shell, holes);
  }

  private LinearRing buildRing(LinearRing ring) {
    List<CoverageEdge> ringEdges = ringEdgesMap.get(ring);
    //-- if ring is not in map, must have been invalid.  Just copy original
    if (ringEdges == null)
      return (LinearRing) ring.copy();
    
    boolean isRemoved = ringEdges.size() == 1
        && ringEdges.get(0).getCoordinates().length == 0;
    if (isRemoved)
      return null;
    
    CoordinateList ptsList = new CoordinateList();
    for (int i = 0; i < ringEdges.size(); i++) {
      Coordinate lastPt = ptsList.size() > 0 
                            ? ptsList.getCoordinate(ptsList.size() - 1)
                            : null;
      boolean dir = isEdgeDirForward(ringEdges, i, lastPt);
      ptsList.add(ringEdges.get(i).getCoordinates(), false, dir);
    }
    Coordinate[] pts = ptsList.toCoordinateArray();
    return ring.getFactory().createLinearRing(pts);
  }

  private boolean isEdgeDirForward(List<CoverageEdge> ringEdges, int index, Coordinate prevPt) {
    int size = ringEdges.size();
    if (size <= 1) return true;
    if (index == 0) {
      //-- if only 2 edges, first one can keep orientation
      if (size == 2)
        return true;
      Coordinate endPt0 = ringEdges.get(0).getEndCoordinate();
      return endPt0.equals2D(ringEdges.get(1).getStartCoordinate())
          || endPt0.equals2D(ringEdges.get(1).getEndCoordinate());
    }
    //-- previous point determines required orientation
    return prevPt.equals2D(ringEdges.get(index).getStartCoordinate());
  }

}
