import Point from "@mapbox/point-geometry";
import bbox from "@turf/bbox";
import booleanIntersects from "@turf/boolean-intersects";
import circle from "@turf/circle";
import { featureCollection } from "@turf/helpers";
import { Feature } from "geojson";
import geojsonvt from "geojson-vt";
import { cloneDeep } from "lodash-es";
import { makeObservable, observable, runInAction } from "mobx";
import {
  Bbox,
  GeomType,
  Feature as ProtomapsFeature,
  TileSource,
  Zxy
} from "protomaps-leaflet";
import Cartographic from "terriajs-cesium/Source/Core/Cartographic";
import CesiumMath from "terriajs-cesium/Source/Core/Math";
import ImageryLayerFeatureInfo from "terriajs-cesium/Source/Scene/ImageryLayerFeatureInfo";
import {
  FeatureCollectionWithCrs,
  toFeatureCollection
} from "../../../Core/GeoJson";
import {
  LAYER_NAME_PROP,
  PROTOMAPS_DEFAULT_TILE_SIZE,
  PROTOMAPS_TILE_BUFFER
} from "../../ImageryProvider/ProtomapsImageryProvider";

/** Extent (of coordinates) of tiles generated by geojson-vt */
export const GEOJSON_VT_EXTENT = 4096;

/** Layer name to use with geojson-vt
 *  This must be used in PaintRules/LabelRules (eg `dataLayer: "layer"`)
 */
export const GEOJSON_SOURCE_LAYER_NAME = "layer";

/** Protomaps Geojson source
 * This source uses geojson-vt to tile geojson data
 * It is designed to be used with ProtomapsImageryProvider
 */
export class ProtomapsGeojsonSource implements TileSource {
  /** Data object from Options */
  private readonly data: string | FeatureCollectionWithCrs;

  /** Resolved geojsonObject (if applicable) */
  @observable.ref
  geojsonObject: FeatureCollectionWithCrs | undefined;

  /** Geojson-vt tileIndex (if applicable) */
  tileIndex: Promise<ReturnType<typeof geojsonvt>> | undefined;

  constructor(url: string | FeatureCollectionWithCrs) {
    makeObservable(this);
    this.data = url;
    if (typeof url !== "string") {
      this.geojsonObject = url;
    }
  }

  /** Fetch geoJSON data (if required) and tile with geojson-vt */
  private async fetchData() {
    let result: FeatureCollectionWithCrs;
    if (typeof this.data === "string") {
      result =
        toFeatureCollection(await (await fetch(this.data)).json()) ??
        featureCollection([]);
    } else {
      result = this.data;
    }

    runInAction(() => (this.geojsonObject = result));

    return geojsonvt(result as geojsonvt.Data, {
      buffer:
        (PROTOMAPS_TILE_BUFFER / PROTOMAPS_DEFAULT_TILE_SIZE) *
        GEOJSON_VT_EXTENT,
      extent: GEOJSON_VT_EXTENT,
      maxZoom: 24
    });
  }

  public async get(
    c: Zxy,
    tileSize: number
  ): Promise<Map<string, ProtomapsFeature[]>> {
    if (!this.tileIndex) {
      this.tileIndex = this.fetchData();
    }

    // request a particular tile
    const tile = (await this.tileIndex).getTile(c.z, c.x, c.y);
    const result = new Map<string, ProtomapsFeature[]>();

    if (tile && tile.features && tile.features.length > 0) {
      result.set(
        GEOJSON_SOURCE_LAYER_NAME,
        geojsonVtTileToProtomapsFeatures(tile.features, tileSize)
      );
    }

    return result;
  }

  public async pickFeatures(
    _x: number,
    _y: number,
    level: number,
    longitude: number,
    latitude: number
  ): Promise<ImageryLayerFeatureInfo[]> {
    if (!this.geojsonObject) return [];
    const featureInfos: ImageryLayerFeatureInfo[] = [];
    // Get rough meters per pixel (at equator) for given zoom level
    const zoomMeters = 156543 / Math.pow(2, level);
    // Create circle with 10 pixel radius to pick features
    const buffer = circle(
      [CesiumMath.toDegrees(longitude), CesiumMath.toDegrees(latitude)],
      10 * zoomMeters,
      {
        steps: 10,
        units: "meters"
      }
    );

    // Create wrappedBuffer with only positive coordinates - this is needed for features which overlap antemeridian
    const wrappedBuffer = cloneDeep(buffer);
    wrappedBuffer.geometry.coordinates.forEach((ring) =>
      ring.forEach((point) => {
        point[0] = point[0] < 0 ? point[0] + 360 : point[0];
      })
    );

    const bufferBbox = bbox(buffer);

    // Get array of all features
    const geojsonFeatures = this.geojsonObject.features;

    const pickedFeatures: Feature[] = [];

    for (let index = 0; index < geojsonFeatures.length; index++) {
      const feature = geojsonFeatures[index];
      if (!feature.bbox) {
        feature.bbox = bbox(feature);
      }

      // Filter by bounding box and then intersection with buffer (to minimize calls to booleanIntersects)
      if (
        Math.max(
          feature.bbox[0],
          // Wrap buffer bbox if necessary
          feature.bbox[0] > 180 ? bufferBbox[0] + 360 : bufferBbox[0]
        ) <=
          Math.min(
            feature.bbox[2],
            // Wrap buffer bbox if necessary
            feature.bbox[2] > 180 ? bufferBbox[2] + 360 : bufferBbox[2]
          ) &&
        Math.max(feature.bbox[1], bufferBbox[1]) <=
          Math.min(feature.bbox[3], bufferBbox[3])
      ) {
        // If we have longitudes greater than 180 - used wrappedBuffer
        if (feature.bbox[0] > 180 || feature.bbox[2] > 180) {
          if (booleanIntersects(feature, wrappedBuffer))
            pickedFeatures.push(feature);
        } else if (booleanIntersects(feature, buffer))
          pickedFeatures.push(feature);
      }
    }

    // Convert pickedFeatures to ImageryLayerFeatureInfos
    pickedFeatures.forEach((f) => {
      const featureInfo = new ImageryLayerFeatureInfo();

      featureInfo.data = f;
      featureInfo.properties = Object.assign(
        { [LAYER_NAME_PROP]: GEOJSON_SOURCE_LAYER_NAME },
        f.properties ?? {}
      );

      if (
        f.geometry.type === "Point" &&
        typeof f.geometry.coordinates[0] === "number" &&
        typeof f.geometry.coordinates[1] === "number"
      ) {
        featureInfo.position = Cartographic.fromDegrees(
          f.geometry.coordinates[0],
          f.geometry.coordinates[1]
        );
      }

      featureInfo.configureDescriptionFromProperties(f.properties);
      featureInfo.configureNameFromProperties(f.properties);

      featureInfos.push(featureInfo);
    });

    return featureInfos;
  }
}

export const geomTypeMap = (
  type: string | null | undefined
): GeomType | null => {
  switch (type) {
    case "Point":
    case "MultiPoint":
      return GeomType.Point;
    case "LineString":
    case "MultiLineString":
      return GeomType.Line;
    case "Polygon":
    case "MultiPolygon":
      return GeomType.Polygon;
    default:
      return null;
  }
};

export function geojsonVtTileToProtomapsFeatures(
  features: geojsonvt.Feature[],
  tileSize: number
): ProtomapsFeature[] {
  const scale = tileSize / GEOJSON_VT_EXTENT;

  return features
    .map((f) => {
      let transformedGeom: Point[][];
      let numVertices: number;

      // Calculate bbox
      const bbox: Bbox = {
        minX: Infinity,
        minY: Infinity,
        maxX: -Infinity,
        maxY: -Infinity
      };

      // Multi-polygon
      if (Array.isArray(f.geometry[0][0])) {
        // Note: the type is incorrect here
        const geom = f.geometry as unknown as [number, number][][];
        transformedGeom = geom.map((g1) =>
          g1.map((g2) => {
            const x = g2[0] * scale;
            const y = g2[1] * scale;

            if (bbox.minX > x) {
              bbox.minX = x;
            }

            if (bbox.maxX < x) {
              bbox.maxX = x;
            }

            if (bbox.minY > y) {
              bbox.minY = y;
            }

            if (bbox.maxY < y) {
              bbox.maxY = y;
            }
            return new Point(x, y);
          })
        );
        numVertices = transformedGeom.reduce<number>(
          (count, current) => count + current.length,
          0
        );
      }
      // Other feature types
      else {
        const geom = f.geometry as [number, number][];
        transformedGeom = [
          geom.map((g1) => {
            const x = g1[0] * scale;
            const y = g1[1] * scale;

            if (bbox.minX > x) {
              bbox.minX = x;
            }

            if (bbox.maxX < x) {
              bbox.maxX = x;
            }

            if (bbox.minY > y) {
              bbox.minY = y;
            }

            if (bbox.maxY < y) {
              bbox.maxY = y;
            }
            return new Point(x, y);
          })
        ];
        numVertices = transformedGeom.length;
      }

      if (f.type === 0) return null;

      const geomType = {
        [1]: GeomType.Point,
        [2]: GeomType.Line,
        [3]: GeomType.Polygon
      }[f.type];

      const feature: ProtomapsFeature = {
        props: { ...(f.tags ?? {}) },
        bbox,
        geomType,
        geom: transformedGeom,
        numVertices
      };

      return feature;
    })
    .filter((f): f is ProtomapsFeature => f !== null);
}
