package com.onthegomap.flatmap;

import com.carrotsearch.hppc.IntArrayList;
import com.carrotsearch.hppc.IntObjectMap;
import com.carrotsearch.hppc.IntStack;
import com.graphhopper.coll.GHIntObjectHashMap;
import com.onthegomap.flatmap.geo.DouglasPeuckerSimplifier;
import com.onthegomap.flatmap.geo.GeoUtils;
import com.onthegomap.flatmap.geo.GeometryException;
import com.onthegomap.flatmap.geo.GeometryType;
import com.onthegomap.flatmap.geo.MutableCoordinateSequence;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import org.locationtech.jts.algorithm.Area;
import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryCollection;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.LinearRing;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.geom.Polygonal;
import org.locationtech.jts.index.strtree.STRtree;
import org.locationtech.jts.operation.buffer.BufferOp;
import org.locationtech.jts.operation.buffer.BufferParameters;
import org.locationtech.jts.operation.linemerge.LineMerger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A collection of utilities for merging features with the same attributes in a rendered tile from {@link
 * Profile#postProcessLayerFeatures(String, int, List)} immediately before a tile is written to the output mbtiles
 * file.
 * <p>
 * Unlike postgis-based solutions that have a full view of all features after they are loaded into the databse, the
 * flatmap engine only sees a single input feature at a time while processing source features, then only has visibility
 * into multiple features when they are grouped into a tile immediately before emitting.  This ends up being sufficient
 * for most real-world use-cases but to do anything more that requires a view of multiple features <em>not</em> within
 * the same tile, {@link Profile} implementations must store input features manually.
 */
public class FeatureMerge {

  private static final Logger LOGGER = LoggerFactory.getLogger(FeatureMerge.class);
  private static final BufferParameters bufferOps = new BufferParameters();

  static {
    bufferOps.setJoinStyle(BufferParameters.JOIN_MITRE);
  }

  /** Don't instantiate */
  private FeatureMerge() {
  }

  /**
   * Combines linestrings with the same set of attributes into a multilinestring where segments with touching endpoints
   * are merged by {@link LineMerger}, removing any linestrings under {@code minLength}.
   * <p>
   * Ignores any non-linestrings and passes them through to the output unaltered.
   * <p>
   * Orders grouped output multilinestring by the index of the first element in that group from the input list.
   *
   * @param features  all features in a layer
   * @param minLength minimum tile pixel length of features to emit, or 0 to emit all merged linestrings
   * @param tolerance after merging, simplify linestrings using this pixel tolerance, or -1 to skip simplification step
   * @param buffer    number of pixels outside the visible tile area to include detail for, or -1 to skip clipping step
   * @return a new list containing all unaltered features in their original order, then each of the merged groups
   * ordered by the index of the first element in that group from the input list.
   */
  public static List<VectorTile.Feature> mergeLineStrings(List<VectorTile.Feature> features,
    double minLength, double tolerance, double buffer) {
    return mergeLineStrings(features, attrs -> minLength, tolerance, buffer);
  }

  /**
   * Merges linestrings with the same attributes like {@link #mergeLineStrings(List, double, double, double)} except
   * with a dynamic length limit  computed by {@code lengthLimitCalculator} for the attributes of each group.
   */
  public static List<VectorTile.Feature> mergeLineStrings(List<VectorTile.Feature> features,
    Function<Map<String, Object>, Double> lengthLimitCalculator, double tolerance, double buffer) {
    List<VectorTile.Feature> result = new ArrayList<>(features.size());
    var groupedByAttrs = groupByAttrs(features, result, GeometryType.LINE);
    for (List<VectorTile.Feature> groupedFeatures : groupedByAttrs) {
      VectorTile.Feature feature1 = groupedFeatures.get(0);
      double lengthLimit = lengthLimitCalculator.apply(feature1.attrs());

      // as a shortcut, can skip line merging only if:
      // - only 1 element in the group
      // - it doesn't need to be clipped
      // - and it can't possibly be filtered out for being too short
      if (groupedFeatures.size() == 1 && buffer == 0d && lengthLimit == 0) {
        result.add(feature1);
      } else {
        LineMerger merger = new LineMerger();
        for (VectorTile.Feature feature : groupedFeatures) {
          try {
            merger.add(feature.geometry().decode());
          } catch (GeometryException e) {
            e.log("Error decoding vector tile feature for line merge: " + feature);
          }
        }
        List<LineString> outputSegments = new ArrayList<>();
        for (Object merged : merger.getMergedLineStrings()) {
          if (merged instanceof LineString line && line.getLength() >= lengthLimit) {
            // re-simplify since some endpoints of merged segments may be unnecessary
            if (line.getNumPoints() > 2 && tolerance >= 0) {
              Geometry simplified = DouglasPeuckerSimplifier.simplify(line, tolerance);
              if (simplified instanceof LineString simpleLineString) {
                line = simpleLineString;
              } else {
                LOGGER.warn("line string merge simplify emitted " + simplified.getGeometryType());
              }
            }
            if (buffer >= 0) {
              removeDetailOutsideTile(line, buffer, outputSegments);
            } else {
              outputSegments.add(line);
            }
          }
        }
        if (!outputSegments.isEmpty()) {
          Geometry newGeometry = GeoUtils.combineLineStrings(outputSegments);
          result.add(feature1.copyWithNewGeometry(newGeometry));
        }
      }
    }
    return result;
  }

  /**
   * Removes any segments from {@code input} where both the start and end are outside the tile boundary (plus {@code
   * buffer}) and puts the resulting segments into {@code output}.
   */
  private static void removeDetailOutsideTile(LineString input, double buffer, List<LineString> output) {
    MutableCoordinateSequence current = new MutableCoordinateSequence();
    CoordinateSequence seq = input.getCoordinateSequence();
    boolean wasIn = false;
    double min = -buffer, max = 256 + buffer;
    double x = seq.getX(0), y = seq.getY(0);
    Envelope env = new Envelope();
    Envelope outer = new Envelope(min, max, min, max);
    for (int i = 0; i < seq.size() - 1; i++) {
      double nextX = seq.getX(i + 1), nextY = seq.getY(i + 1);
      env.init(x, nextX, y, nextY);
      boolean nowIn = env.intersects(outer);
      if (nowIn || wasIn) {
        current.addPoint(x, y);
      } else { // out
        // wait to flush until 2 consecutive outs
        if (!current.isEmpty()) {
          if (current.size() >= 2) {
            output.add(GeoUtils.JTS_FACTORY.createLineString(current));
          }
          current = new MutableCoordinateSequence();
        }
      }
      wasIn = nowIn;
      x = nextX;
      y = nextY;
    }

    // last point
    double lastX = seq.getX(seq.size() - 1), lastY = seq.getY(seq.size() - 1);
    env.init(x, lastX, y, lastY);
    if (env.intersects(outer) || wasIn) {
      current.addPoint(lastX, lastY);
    }

    if (current.size() >= 2) {
      output.add(GeoUtils.JTS_FACTORY.createLineString(current));
    }
  }

  /**
   * Combines polygons with the same set of attributes into a multipolygon where overlapping/touching polygons are
   * combined into fewer polygons covering the same area.
   * <p>
   * Ignores any non-polygons and passes them through to the output unaltered.
   * <p>
   * Orders grouped output multipolygon by the index of the first element in that group from the input list.
   *
   * @param features all features in a layer
   * @param minArea  minimum area in square tile pixels of polygons to emit
   * @return a new list containing all unaltered features in their original order, then each of the merged groups
   * ordered by the index of the first element in that group from the input list.
   * @throws GeometryException if an error occurs encoding the combined geometry
   */
  public static List<VectorTile.Feature> mergeOverlappingPolygons(List<VectorTile.Feature> features, double minArea)
    throws GeometryException {
    return mergeNearbyPolygons(
      features,
      minArea,
      0,
      0,
      0
    );
  }

  /**
   * Combines polygons with the same set of attributes within {@code minDist} from eachother, expanding then contracting
   * the merged geometry by {@code buffer} to combine polygons that are almost touching.
   * <p>
   * Ignores any non-polygons and passes them through to the output unaltered.
   * <p>
   * Orders grouped output multipolygon by the index of the first element in that group from the input list.
   *
   * @param features    all features in a layer
   * @param minArea     minimum area in square tile pixels of polygons to emit
   * @param minHoleArea the minimum area in square tile pixels of inner rings of polygons to emit
   * @param minDist     the minimum threshold in tile pixels between polygons to combine into a group
   * @param buffer      the amount (in tile pixels) to expand then contract polygons by in order to combine
   *                    almost-touching polygons
   * @return a new list containing all unaltered features in their original order, then each of the merged groups
   * ordered by the index of the first element in that group from the input list.
   * @throws GeometryException if an error occurs encoding the combined geometry
   */
  public static List<VectorTile.Feature> mergeNearbyPolygons(List<VectorTile.Feature> features, double minArea,
    double minHoleArea, double minDist, double buffer) throws GeometryException {
    List<VectorTile.Feature> result = new ArrayList<>(features.size());
    Collection<List<VectorTile.Feature>> groupedByAttrs = groupByAttrs(features, result, GeometryType.POLYGON);
    for (List<VectorTile.Feature> groupedFeatures : groupedByAttrs) {
      List<Polygon> outPolygons = new ArrayList<>();
      VectorTile.Feature feature1 = groupedFeatures.get(0);
      List<Geometry> geometries = new ArrayList<>(groupedFeatures.size());
      for (var feature : groupedFeatures) {
        try {
          geometries.add(feature.geometry().decode());
        } catch (GeometryException e) {
          e.log("Error decoding vector tile feature for polygon merge: " + feature);
        }
      }
      Collection<List<Geometry>> groupedByProximity = groupPolygonsByProximity(geometries, minDist);
      for (List<Geometry> polygonGroup : groupedByProximity) {
        Geometry merged;
        if (polygonGroup.size() > 1) {
          if (buffer > 0) {
            // there are 2 ways to merge polygons:
            // 1) bufferUnbuffer: merged.buffer(amount).buffer(-amount)
            // 2) bufferUnionUnbuffer: polygon.buffer(amount) on each polygon then merged.union().buffer(-amount)
            // #1 is faster on average, but can become very slow and use a lot of memory when there is a large overlap
            // between buffered polygons (i.e. most of them are smaller than the buffer amount) so we use #2 to avoid
            // spinning for a very long time on very dense tiles.
            // TODO use some heuristic to choose bufferUnbuffer vs. bufferUnionUnbuffer based on the number small
            //      polygons in the group?
            merged = bufferUnionUnbuffer(buffer, polygonGroup);
          } else {
            merged = buffer(buffer, GeoUtils.createGeometryCollection(polygonGroup));
          }
          if (!(merged instanceof Polygonal) || merged.getEnvelopeInternal().getArea() < minArea) {
            continue;
          }
          merged = GeoUtils.snapAndFixPolygon(merged).reverse();
        } else {
          merged = polygonGroup.get(0);
          if (!(merged instanceof Polygonal) || merged.getEnvelopeInternal().getArea() < minArea) {
            continue;
          }
        }
        extractPolygons(merged, outPolygons, minArea, minHoleArea);
      }
      if (!outPolygons.isEmpty()) {
        Geometry combined = GeoUtils.combinePolygons(outPolygons);
        result.add(feature1.copyWithNewGeometry(combined));
      }
    }
    return result;
  }


  /**
   * Returns all the clusters from {@code geometries} where elements in the group are less than {@code minDist} from
   * another element in the group.
   */
  public static Collection<List<Geometry>> groupPolygonsByProximity(List<Geometry> geometries, double minDist) {
    IntObjectMap<IntArrayList> adjacencyList = extractAdjacencyList(geometries, minDist);
    List<IntArrayList> groups = extractConnectedComponents(adjacencyList, geometries.size());
    return groups.stream().map(ids -> {
      List<Geometry> geomsInGroup = new ArrayList<>(ids.size());
      for (var cursor : ids) {
        geomsInGroup.add(geometries.get(cursor.value));
      }
      return geomsInGroup;
    }).toList();
  }

  /**
   * Returns each group of vector tile features that share the exact same attributes.
   *
   * @param features     the set of input features
   * @param others       list to add any feature that does not match {@code geometryType}
   * @param geometryType the type of geometries to return in the result
   * @return all the elements from {@code features} of type {@code geometryType} grouped by attributes
   */
  public static Collection<List<VectorTile.Feature>> groupByAttrs(
    List<VectorTile.Feature> features,
    List<VectorTile.Feature> others,
    GeometryType geometryType
  ) {
    LinkedHashMap<Map<String, Object>, List<VectorTile.Feature>> groupedByAttrs = new LinkedHashMap<>();
    for (VectorTile.Feature feature : features) {
      if (feature.geometry().geomType() != geometryType) {
        // just ignore and pass through non-polygon features
        others.add(feature);
      } else {
        groupedByAttrs
          .computeIfAbsent(feature.attrs(), k -> new ArrayList<>())
          .add(feature);
      }
    }
    return groupedByAttrs.values();
  }

  /**
   * Merges nearby polygons by expanding each individual polygon by {@code buffer}, unioning them, and contracting the
   * result.
   */
  private static Geometry bufferUnionUnbuffer(double buffer, List<Geometry> polygonGroup) {
    /*
     * A simpler alternative that might initially appear faster would be:
     *
     * Geometry merged = GeoUtils.createGeometryCollection(polygonGroup);
     * merged = buffer(buffer, merged);
     * merged = unbuffer(buffer, merged);
     *
     * But since buffer() is faster than union() only when the amount of overlap is small,
     * this technique becomes very slow for merging many small nearby polygons.
     *
     * The following approach is slower most of the time, but faster on average because it does
     * not choke on dense nearby polygons:
     */
    for (int i = 0; i < polygonGroup.size(); i++) {
      polygonGroup.set(i, buffer(buffer, polygonGroup.get(i)));
    }
    Geometry merged = GeoUtils.createGeometryCollection(polygonGroup);
    merged = union(merged);
    merged = unbuffer(buffer, merged);
    return merged;
  }

  // these small wrappers make performance profiling with jvisualvm easier...
  private static Geometry union(Geometry merged) {
    return merged.union();
  }

  private static Geometry unbuffer(double buffer, Geometry merged) {
    return new BufferOp(merged, bufferOps).getResultGeometry(-buffer);
  }

  private static Geometry buffer(double buffer, Geometry merged) {
    return new BufferOp(merged, bufferOps).getResultGeometry(buffer);
  }

  /**
   * Puts all polygons within {@code geom} over {@code minArea} into {@code result}, removing holes under {@code
   * minHoleArea}.
   */
  private static void extractPolygons(Geometry geom, List<Polygon> result, double minArea, double minHoleArea) {
    if (geom instanceof Polygon poly) {
      if (Area.ofRing(poly.getExteriorRing().getCoordinateSequence()) > minArea) {
        int innerRings = poly.getNumInteriorRing();
        if (minHoleArea > 0 && innerRings > 0) {
          List<LinearRing> rings = new ArrayList<>(innerRings);
          for (int i = 0; i < innerRings; i++) {
            LinearRing innerRing = poly.getInteriorRingN(i);
            if (Area.ofRing(innerRing.getCoordinateSequence()) > minArea) {
              rings.add(innerRing);
            }
          }
          if (rings.size() != innerRings) {
            poly = GeoUtils.createPolygon(poly.getExteriorRing(), rings);
          }
        }
        result.add(poly);
      }
    } else if (geom instanceof GeometryCollection) {
      for (int i = 0; i < geom.getNumGeometries(); i++) {
        extractPolygons(geom.getGeometryN(i), result, minArea, minHoleArea);
      }
    }
  }

  /** Returns a map from index in {@code geometries} to index of every other geometry within {@code minDist}. */
  private static IntObjectMap<IntArrayList> extractAdjacencyList(List<Geometry> geometries, double minDist) {
    STRtree envelopeIndex = new STRtree();
    for (int i = 0; i < geometries.size(); i++) {
      Geometry a = geometries.get(i);
      Envelope env = a.getEnvelopeInternal().copy();
      env.expandBy(minDist);
      envelopeIndex.insert(env, i);
    }
    IntObjectMap<IntArrayList> result = new GHIntObjectHashMap<>();
    for (int _i = 0; _i < geometries.size(); _i++) {
      int i = _i;
      Geometry a = geometries.get(i);
      envelopeIndex.query(a.getEnvelopeInternal(), object -> {
        if (object instanceof Integer j) {
          Geometry b = geometries.get(j);
          if (a.isWithinDistance(b, minDist)) {
            addAdjacencyEntry(result, i, j);
            addAdjacencyEntry(result, j, i);
          }
        }
      });
    }
    return result;
  }

  private static void addAdjacencyEntry(IntObjectMap<IntArrayList> result, int from, int to) {
    IntArrayList ilist = result.get(from);
    if (ilist == null) {
      result.put(from, ilist = new IntArrayList());
    }
    ilist.add(to);
  }

  static List<IntArrayList> extractConnectedComponents(IntObjectMap<IntArrayList> adjacencyList, int numItems) {
    List<IntArrayList> result = new ArrayList<>();
    BitSet visited = new BitSet(numItems);

    for (int i = 0; i < numItems; i++) {
      if (!visited.get(i)) {
        visited.set(i, true);
        IntArrayList group = new IntArrayList();
        group.add(i);
        result.add(group);
        depthFirstSearch(i, group, adjacencyList, visited);
      }
    }
    return result;
  }

  private static void depthFirstSearch(int startNode, IntArrayList group, IntObjectMap<IntArrayList> adjacencyList,
    BitSet visited) {
    // do iterate (not recursive) depth-first search since when merging z13 building in very dense areas like Jakarta
    // recursive calls can generate a stackoverflow error
    IntStack stack = new IntStack();
    stack.add(startNode);
    while (!stack.isEmpty()) {
      int start = stack.pop();
      IntArrayList adjacent = adjacencyList.get(start);
      if (adjacent != null) {
        for (var cursor : adjacent) {
          int index = cursor.value;
          if (!visited.get(index)) {
            visited.set(index, true);
            group.add(index);
            // technically, depth-first search would push onto the stack in reverse order but don't bother since
            // ordering doesn't matter
            stack.push(index);
          }
        }
      }
    }
  }
}
