import buffer from '@turf/buffer';
import difference from '@turf/difference';
import intersect from '@turf/intersect';
import { featureCollection, flatten } from '@turf/turf';
import union from '@turf/union';
import {
  Feature as GeoJSONFeature,
  MultiPolygon as GeoJSONMultiPolygon,
  Polygon as GeoJSONPolygon
} from 'geojson';
import isNil from 'lodash/isNil';
import { Extent as OlExtent } from 'ol/extent';
import OlFeature from 'ol/Feature';
import OlFormatGeoJSON from 'ol/format/GeoJSON';
import OlGeometry from 'ol/geom/Geometry';
import OlGeomLineString from 'ol/geom/LineString';
import OlGeomMultiLineString from 'ol/geom/MultiLineString';
import OlGeomMultiPoint from 'ol/geom/MultiPoint';
import OlGeomMultiPolygon from 'ol/geom/MultiPolygon';
import OlGeomPoint from 'ol/geom/Point';
import OlGeomPolygon from 'ol/geom/Polygon';
import { ProjectionLike } from 'ol/proj';
import polygonSplitter from 'polygon-splitter';

/**
 * @template {OlGeomGeometry} T
 * @param {OlFeature<T>|T} featureOrGeom
 */
function toGeom<Geom extends OlGeometry>(featureOrGeom: OlFeature<Geom> | Geom) {
  if (featureOrGeom instanceof OlFeature) {
    const geom = featureOrGeom.getGeometry();
    if (geom === undefined) {
      throw new Error('Feature has no geometry.');
    }
    return geom;
  } else {
    return featureOrGeom;
  }
}

/**
 * Helper class for the geospatial analysis. Makes use of
 * [Turf.js](http://turfjs.org/).
 *
 * @class GeometryUtil
 */
class GeometryUtil {

  /**
   * The prefix used to detect multi geometries.
   * @ignore
   */
  static MULTI_GEOM_PREFIX = 'Multi';

  /**
   * Splits an OlFeature with/or ol.geom.Polygon by an OlFeature with/or ol.geom.LineString
   * into an array of instances of OlFeature with/or ol.geom.Polygon.
   * If the target polygon (first param) is of type ol.Feature it will return an
   * array with ol.Feature. If the target polygon (first param) is of type
   * ol.geom.Geometry it will return an array with ol.geom.Geometry.
   *
   * @param {OlFeature<OlGeomPolygon> | OlGeomPolygon} polygon The polygon geometry to split.
   * @param {OlFeature<OlGeomLineString> | OlGeomLineString} line The line geometry to split the polygon
   *  geometry with.
   * @param {import("ol/proj").ProjectionLike} projection The EPSG code of the input features.
   *  Default is to EPSG:3857.
   * @returns {OlFeature[] | OlGeomPolygon[]} An array of instances of OlFeature
   *  with/or ol.geom.Polygon
   */
  static splitByLine(
    polygon: OlFeature<OlGeomPolygon> | OlGeomPolygon,
    line: OlFeature<OlGeomLineString>,
    projection: ProjectionLike = 'EPSG:3857'
  ): OlGeomPolygon[] | OlFeature<OlGeomPolygon>[] {
    const returnFeature = polygon instanceof OlFeature;
    const geometries = GeometryUtil.splitGeometryByLine(toGeom(polygon), toGeom(line), projection);
    if (returnFeature) {
      return geometries.map(geom => new OlFeature(geom));
    } else {
      return geometries;
    }
  }

  /**
   * Splits an ol.geom.Polygon by an ol.geom.LineString
   * into an array of instances of ol.geom.Polygon.
   *
   * @param {OlGeomPolygon} polygon The polygon geometry to split.
   * @param {OlGeomLineString} line The line geometry to split the polygon
   *  geometry with.
   * @param {ProjectionLike} projection The EPSG code of the input features.
   *  Default is to EPSG:3857.
   * @returns {OlGeomPolygon[]} An array of instances of ol.geom.Polygon
   */
  static splitGeometryByLine(
    polygon: OlGeomPolygon,
    line: OlGeomLineString,
    projection: ProjectionLike = 'EPSG:3857'
  ): OlGeomPolygon[] {
    const geoJsonFormat = new OlFormatGeoJSON({
      dataProjection: 'EPSG:4326',
      featureProjection: projection
    });

    const polyJson = geoJsonFormat.writeGeometryObject(polygon);
    const lineJson = geoJsonFormat.writeGeometryObject(line);

    const result = polygonSplitter(polyJson, lineJson);

    const flattened = flatten(result);

    return flattened.features.map((geojsonFeature: any) => {
      return geoJsonFormat.readGeometry(geojsonFeature.geometry) as OlGeomPolygon;
    });
  }

  /**
   * Adds a buffer to a given geometry.
   *
   * If the target is of type ol.Feature it will return an ol.Feature.
   * If the target is of type ol.geom.Geometry it will return ol.geom.Geometry.
   *
   * @param {OlGeometry | OlFeature} geometryOrFeature The geometry.
   * @param {number} radius The buffer to add in meters.
   * @param {string} projection The projection of the input geometry as EPSG code.
   *  Default is to EPSG:3857.
   *
   * @returns {OlGeometry | OlFeature} The geometry or feature with the added buffer.
   */
  static addBuffer(
    geometryOrFeature: OlFeature<OlGeometry> | OlGeometry,
    radius: number = 0,
    projection: ProjectionLike = 'EPSG:3857'
  ) {
    if (geometryOrFeature instanceof OlFeature) {
      return new OlFeature(GeometryUtil.addGeometryBuffer(toGeom(geometryOrFeature), radius, projection));
    } else {
      return GeometryUtil.addGeometryBuffer(geometryOrFeature, radius, projection);
    }
  }

  /**
   * Adds a buffer to a given geometry.
   *
   * @param {OlGeometry} geometry The geometry.
   * @param {number} radius The buffer to add in meters.
   * @param {string} projection The projection of the input geometry as EPSG code.
   *  Default is to EPSG:3857.
   *
   * @returns {OlGeometry} The geometry with the added buffer.
   */
  static addGeometryBuffer(geometry: OlGeometry, radius: number = 0, projection: ProjectionLike = 'EPSG:3857') {
    if (radius === 0) {
      return geometry;
    }
    const geoJsonFormat = new OlFormatGeoJSON({
      dataProjection: 'EPSG:4326',
      featureProjection: projection
    });
    const geoJson = geoJsonFormat.writeGeometryObject(geometry);
    if (geoJson.type === 'GeometryCollection') {
      return;
    }
    const buffered = buffer(geoJson, radius, {
      units: 'meters'
    });
    return geoJsonFormat.readGeometry(buffered?.geometry);
  }

  /**
   * Merges multiple geometries into one MultiGeometry.
   *
   * @param {(OlGeomMultiPoint|OlGeomPoint)[]|(OlGeomMultiPolygon|OlGeomPolygon)[]|
   *   (OlGeomMultiLineString|OlGeomLineString)[]} geometries An array of ol.geom.geometries;
   * @returns {OlGeomMultiPoint|OlGeomMultiPolygon|OlGeomMultiLineString} A Multigeometry.
   */
  static mergeGeometries<Geom extends OlGeometry>(geometries: Geom[]) {
    // split all multi-geometries to simple ones if passed geometries are
    // multi-geometries
    const separateGeometries = GeometryUtil.separateGeometries(geometries);

    if (separateGeometries[0] instanceof OlGeomPolygon) {
      const multiGeom = new OlGeomMultiPolygon([]);
      for (const geom of separateGeometries) {
        multiGeom.appendPolygon(geom as OlGeomPolygon);
      }
      return multiGeom;
    } else if (separateGeometries[0] instanceof OlGeomLineString) {
      const multiGeom = new OlGeomMultiLineString([]);
      for (const geom of separateGeometries) {
        multiGeom.appendLineString(geom as OlGeomLineString);
      }
      return multiGeom;
    } else {
      const multiGeom = new OlGeomMultiPoint([]);
      for (const geom of separateGeometries) {
        multiGeom.appendPoint(geom as OlGeomPoint);
      }
      return multiGeom;
    }
  }

  /**
   * Splits an array of geometries (and multi geometries) or a single MultiGeom
   * into an array of single geometries.
   *
   * @param {} geometry An (array of) ol.geom.geometries;
   * @returns {(OlGeomPoint|OlGeomLineString|OlGeomPolygon)[]} An array of geometries.
   */
  static separateGeometries(geometry: OlGeometry | OlGeometry[]): OlGeometry[] {
    if (Array.isArray(geometry)) {
      return geometry.flatMap(geom => GeometryUtil.separateGeometries(geom));
    }
    if (geometry instanceof OlGeomMultiPolygon) {
      return geometry.getPolygons();
    }
    if (geometry instanceof OlGeomMultiLineString) {
      return geometry.getLineStrings();
    }
    if (geometry instanceof OlGeomMultiPoint) {
      return geometry.getPoints();
    }
    return [geometry]; // Return simple geometry as array
  }

  /**
   * Takes two or more polygons and returns a combined (Multi-)polygon.
   *
   * @param {OlFeature<OlGeomPolygon>[] | OlFeature<OlGeomPolygon | OlGeomMultiPolygon>>[]} inputPolygonalObjects An
   *        array of ol.Feature or ol.geom.Geometry instances of type (Multi)-Polygon.
   * @param {ProjectionLike} projection The projection of the input polygons as EPSG code.
   *  Default is to EPSG:3857.
   * @returns {OlGeomMultiPolygon|OlGeomPolygon|OlFeature<OlGeomMultiPolygon|OlGeomPolygon>} A Feature or Geometry with
   * the combined area of the (Multi-)polygons.
   */
  static union(
    inputPolygonalObjects: OlGeomPolygon[] | OlFeature<OlGeomPolygon | OlGeomMultiPolygon>[],
    projection: ProjectionLike = 'EPSG:3857'
  ): OlGeomMultiPolygon | OlGeomPolygon | OlFeature<OlGeomMultiPolygon|OlGeomPolygon> {
    const geometries = inputPolygonalObjects.map(toGeom) as OlGeomPolygon[] | OlGeomMultiPolygon[];
    const unionGeometry = GeometryUtil.unionGeometries(geometries, projection);
    if (inputPolygonalObjects[0] instanceof OlFeature) {
      return new OlFeature(unionGeometry);
    } else {
      return unionGeometry;
    }
  }

  /**
   * Takes two or more polygons and returns a combined (Multi-)polygon.
   *
   * @param {OlGeomPolygon[]} polygons An array of ol.geom.Geometry instances of type (Multi-)polygon.
   * @param {string} projection The projection of the input polygons as EPSG code.
   *  Default is to EPSG:3857.
   * @returns {OlGeomMultiPolygon|OlGeomPolygon} A FGeometry with the combined area of the (Multi-)polygons.
   */
  static unionGeometries(polygons: OlGeomPolygon[] | OlGeomMultiPolygon[], projection: ProjectionLike = 'EPSG:3857'):
    OlGeomMultiPolygon | OlGeomPolygon
  {
    const geoJsonFormat = new OlFormatGeoJSON({
      dataProjection: 'EPSG:4326',
      featureProjection: projection
    });

    const pp = polygons
      .map((p: OlGeomPolygon | OlGeomMultiPolygon) => {
        if (p instanceof OlGeomMultiPolygon) {
          return geoJsonFormat.writeFeatureObject(new OlFeature(p)) as GeoJSONFeature<GeoJSONMultiPolygon>;
        } else {
          return geoJsonFormat.writeFeatureObject(new OlFeature(p)) as GeoJSONFeature<GeoJSONPolygon>;
        }
      });

    const unionGeometry = union(featureCollection<GeoJSONMultiPolygon | GeoJSONPolygon>(pp));

    return (geoJsonFormat.readFeature(unionGeometry) as OlFeature).getGeometry() as OlGeomMultiPolygon | OlGeomPolygon;
  }

  /**
   * Finds the difference between two polygons by clipping the second polygon from the first.
   *
   * If both polygons are of type ol.Feature it will return an ol.Feature.
   * Else it will return an ol.geom.Geometry.
   *
   * @param {OlGeomPolygon|OlGeomMultiPolygon|OlFeature<OlGeomPolygon|OlGeomMultiPolygon>} polygon1
   * @param {OlGeomPolygon|OlGeomMultiPolygon|OlFeature<OlGeomPolygon|OlGeomMultiPolygon>} polygon2
   * @param {string} projection The projection of the input polygons as EPSG code.
   *  Default is to EPSG:3857.
   *
   * @returns {OlGeomPolygon|OlGeomMultiPolygon|OlFeature<OlGeomPolygon|OlGeomMultiPolygon>} A Feature or geometry
   *  with the area of polygon1 excluding the area of polygon2.
   */
  static difference(
    polygon1: OlFeature<OlGeomPolygon> | OlGeomPolygon,
    polygon2: OlFeature<OlGeomPolygon> | OlGeomPolygon,
    projection: ProjectionLike = 'EPSG:3857'
  ): OlGeomMultiPolygon | OlGeomPolygon | OlFeature<OlGeomMultiPolygon|OlGeomPolygon> {
    const differenceGeometry = GeometryUtil.geometryDifference(toGeom(polygon1), toGeom(polygon2), projection);
    if (polygon1 instanceof OlFeature && polygon2 instanceof OlFeature) {
      return new OlFeature(differenceGeometry);
    } else {
      return differenceGeometry;
    }
  }

  /**
   * Finds the difference between two polygons by clipping the second polygon from the first.
   *
   * @param {OlGeomPolygon|OlGeomMultiPolygon} polygon1 An ol.geom.Geometry
   * @param {OlGeomPolygon|OlGeomMultiPolygon} polygon2 An ol.geom.Geometry
   * @param {string} projection The projection of the input polygons as EPSG code.
   *  Default is to EPSG:3857.
   *
   * @returns {OlGeomPolygon|OlGeomMultiPolygon} A with the area
   *  of polygon1 excluding the area of polygon2.
   */
  static geometryDifference(
    polygon1: OlGeomPolygon,
    polygon2: OlGeomPolygon,
    projection: ProjectionLike = 'EPSG:3857'
  ): OlGeomMultiPolygon | OlGeomPolygon {
    const geoJsonFormat = new OlFormatGeoJSON<OlFeature<OlGeomPolygon>>({
      dataProjection: 'EPSG:4326',
      featureProjection: projection
    });
    const geojson1 = geoJsonFormat.writeFeatureObject(new OlFeature(polygon1)) as GeoJSONFeature<GeoJSONPolygon>;
    const geojson2 = geoJsonFormat.writeFeatureObject(new OlFeature(polygon2)) as GeoJSONFeature<GeoJSONPolygon>;

    const coll = featureCollection([geojson1, geojson2]);

    const intersection = difference(coll);
    const feature = geoJsonFormat.readFeature(intersection);
    return (feature as OlFeature).getGeometry() as OlGeomMultiPolygon | OlGeomPolygon;
  }

  /**
   * Takes two polygons and finds their intersection.
   *
   * If both polygons are of type ol.Feature it will return an ol.Feature.
   * Else it will return an ol.geom.Geometry.
   *
   * @param {OlGeomPolygon|OlGeomMultiPolygon|OlFeature<OlGeomPolygon|OlGeomMultiPolygon>} polygon1
   * @param {OlGeomPolygon|OlGeomMultiPolygon|OlFeature<OlGeomPolygon|OlGeomMultiPolygon>} polygon2
   * @param {string} projection The projection of the input polygons as EPSG code.
   *  Default is to EPSG:3857.
   *
   * @returns {OlGeomPolygon|OlGeomMultiPolygon|OlFeature<OlGeomPolygon|OlGeomMultiPolygon>|null} A Feature or Geometry
   * with the shared area of the two polygons or null if the polygons don't intersect.
   */
  static intersection(
    polygon1: OlFeature<OlGeomPolygon | OlGeomMultiPolygon> | OlGeomPolygon,
    polygon2: OlFeature<OlGeomPolygon | OlGeomMultiPolygon> | OlGeomPolygon,
    projection: ProjectionLike = 'EPSG:3857'
  ): OlGeomMultiPolygon | OlGeomPolygon | OlFeature<OlGeomPolygon|OlGeomMultiPolygon> | undefined {
    const intersectionGeometry = GeometryUtil.geometryIntersection(toGeom(polygon1), toGeom(polygon2), projection);
    if (isNil(intersectionGeometry)) {
      return undefined;
    }
    if (polygon1 instanceof OlFeature && polygon2 instanceof OlFeature) {
      return new OlFeature(intersectionGeometry);
    } else {
      return intersectionGeometry;
    }
  }

  /**
   * Takes two polygons and finds their intersection.
   *
   * @param {OlGeomPolygon|OlGeomMultiPolygon} polygon1 An ol.geom.Geometry
   * @param {OlGeomPolygon|OlGeomMultiPolygon} polygon2 An ol.geom.Geometry
   * @param {string} projection The projection of the input polygons as EPSG code.
   *  Default is to EPSG:3857.
   *
   * @returns {OlGeomPolygon|OlGeomMultiPolygon|null} A Geometry with the
   * shared area of the two polygons or null if the polygons don't intersect.
   */
  static geometryIntersection(
    polygon1: OlGeomPolygon | OlGeomMultiPolygon,
    polygon2: OlGeomPolygon | OlGeomMultiPolygon,
    projection: ProjectionLike = 'EPSG:3857'
  ): OlGeomMultiPolygon | OlGeomPolygon | undefined {
    const geoJsonFormat = new OlFormatGeoJSON({
      dataProjection: 'EPSG:4326',
      featureProjection: projection
    });
    const geojson1 = polygon1 instanceof OlGeomMultiPolygon ?
      geoJsonFormat.writeFeatureObject(new OlFeature(polygon1)) as GeoJSONFeature<GeoJSONMultiPolygon> :
      geoJsonFormat.writeFeatureObject(new OlFeature(polygon1)) as GeoJSONFeature<GeoJSONPolygon>;
    const geojson2 = polygon2 instanceof OlGeomMultiPolygon ?
      geoJsonFormat.writeFeatureObject(new OlFeature(polygon2)) as GeoJSONFeature<GeoJSONMultiPolygon> :
      geoJsonFormat.writeFeatureObject(new OlFeature(polygon2)) as GeoJSONFeature<GeoJSONPolygon>;

    const intersection = intersect(featureCollection<GeoJSONMultiPolygon | GeoJSONPolygon>([geojson1, geojson2]));

    if (!intersection) {
      return undefined;
    }

    const feature = geoJsonFormat.readFeature(intersection);
    return (feature as OlFeature).getGeometry() as OlGeomMultiPolygon | OlGeomPolygon;
  }

  static getPolygonFromExtent(extent?: OlExtent | null): OlGeomPolygon | undefined {
    if (isNil(extent) || extent?.length !== 4) {
      return;
    }
    const [minX, minY, maxX, maxY] = extent;
    return new OlGeomPolygon([
      [
        [minX, minY],
        [minX, maxY],
        [maxX, maxY],
        [maxX, minY],
        [minX, minY]
      ]
    ]);
  }
}
export default GeometryUtil;
