import { intersects } from "ol/extent";
import type { Extent } from "ol/extent";
import type OlMap from "ol/Map";
import { toLonLat } from "ol/proj";
import type VectorTileSource from "ol/source/VectorTile";
import type { Coordinate } from "ol/coordinate";
import {
  getLoadedVectorTiles,
  getVectorTileFeatureId,
  getVectorTileFeatureProperties,
} from "./vectorTileUtils";

type TileKey = string;

export type TileFeatureInstance = {
  instanceKey: string;
  z: number;
  featureId: string;
  properties: Record<string, any>;
  geometryType: string;
  tileExtent: Extent;
  flatCoordinates: number[];
  stride: number;
};

type TileFeatureSnapshot = {
  signature: string;
  instancesById: Map<string, TileFeatureInstance[]>;
  instances: TileFeatureInstance[];
};

export type PipeFeatureFragment = {
  instanceKey: string;
  id: string;
  diameter: number;
  length: number;
  path: [number, number][];
  position: [number, number];
  angle: number;
  timestamps: number[];
  fragmentLength: number;
};

type RegisteredTile = {
  tile: any;
  sourceRevision: string | number;
  signatureRevision: string | number;
  z: number;
};

const EARTH_RADIUS_KM = 6371.0088;
const DEGREES_TO_RADIANS = Math.PI / 180;
const RADIANS_TO_DEGREES = 180 / Math.PI;

const getTileCoord = (tile: any): [number, number, number] | null => {
  const coord =
    typeof tile.getTileCoord === "function"
      ? tile.getTileCoord()
      : tile.tileCoord ?? tile.tileCoord_;
  if (!Array.isArray(coord) || coord.length < 3) return null;
  return [Number(coord[0]), Number(coord[1]), Number(coord[2])];
};

const getTileKey = (sourceKey: string, z: number, x: number, y: number) =>
  `${sourceKey}/${z}/${x}/${y}`;

const getInstanceKey = (
  sourceKey: string,
  z: number,
  x: number,
  y: number,
  featureOrdinal: number,
) => `${sourceKey}/${z}/${x}/${y}/${featureOrdinal}`;

const normalizeExtent = (extent: Extent): Extent => [
  Math.min(extent[0], extent[2]),
  Math.min(extent[1], extent[3]),
  Math.max(extent[0], extent[2]),
  Math.max(extent[1], extent[3]),
];

const getTileGrid = (source: VectorTileSource, map?: OlMap) => {
  const sourceAny = source as any;
  const projection = map?.getView().getProjection();
  if (projection && typeof sourceAny.getTileGridForProjection === "function") {
    return sourceAny.getTileGridForProjection(projection);
  }
  return typeof sourceAny.getTileGrid === "function"
    ? sourceAny.getTileGrid()
    : sourceAny.tileGrid ?? sourceAny.tileGrid_;
};

const getTileExtent = (
  source: VectorTileSource,
  tile: any,
  z: number,
  x: number,
  y: number,
): Extent | null => {
  const tileGrid = getTileGrid(source);
  if (tileGrid && typeof tileGrid.getTileCoordExtent === "function") {
    return normalizeExtent(tileGrid.getTileCoordExtent([z, x, y]) as Extent);
  }
  const extent =
    typeof tile.getExtent === "function"
      ? tile.getExtent()
      : tile.extent ?? tile.extent_;
  return Array.isArray(extent) ? normalizeExtent(extent as Extent) : null;
};

const getFeatureGeometry = (renderFeature: any) => {
  if (typeof renderFeature.getGeometry === "function") {
    return renderFeature.getGeometry();
  }
  return renderFeature;
};

const getGeometryType = (geometry: any): string => {
  if (typeof geometry.getType === "function") return String(geometry.getType());
  if (typeof geometry.getType === "string") return geometry.getType;
  return "";
};

const getFlatCoordinates = (geometry: any): number[] => {
  if (typeof geometry.getFlatCoordinates === "function") {
    return Array.from(geometry.getFlatCoordinates() ?? []);
  }
  if (Array.isArray(geometry.flatCoordinates)) {
    return geometry.flatCoordinates.slice();
  }
  if (Array.isArray(geometry.flatCoordinates_)) {
    return geometry.flatCoordinates_.slice();
  }
  return [];
};

const getStride = (geometry: any) =>
  typeof geometry.getStride === "function"
    ? Number(geometry.getStride())
    : Number(geometry.stride ?? geometry.stride_ ?? 2);

export const lineStringFromFlatCoordinates = (
  flatCoordinates: number[],
  stride: number,
): Coordinate[] => {
  const coordinates: Coordinate[] = [];
  for (let i = 0; i + 1 < flatCoordinates.length; i += stride) {
    coordinates.push([flatCoordinates[i], flatCoordinates[i + 1]]);
  }
  return coordinates;
};

const clipSegmentToExtent = (
  start: Coordinate,
  end: Coordinate,
  extent: Extent,
): [Coordinate, Coordinate] | null => {
  const [minX, minY, maxX, maxY] = extent;
  const dx = end[0] - start[0];
  const dy = end[1] - start[1];
  let t0 = 0;
  let t1 = 1;

  const update = (p: number, q: number) => {
    if (p === 0) return q >= 0;
    const r = q / p;
    if (p < 0) {
      if (r > t1) return false;
      if (r > t0) t0 = r;
    } else {
      if (r < t0) return false;
      if (r < t1) t1 = r;
    }
    return true;
  };

  if (
    !update(-dx, start[0] - minX) ||
    !update(dx, maxX - start[0]) ||
    !update(-dy, start[1] - minY) ||
    !update(dy, maxY - start[1])
  ) {
    return null;
  }

  return [
    [start[0] + t0 * dx, start[1] + t0 * dy],
    [start[0] + t1 * dx, start[1] + t1 * dy],
  ];
};

const sameCoordinate = (a: Coordinate, b: Coordinate) =>
  a[0] === b[0] && a[1] === b[1];

export const clipLineStringPartsToExtent = (
  coordinates: Coordinate[],
  extent: Extent,
): Coordinate[][] => {
  const parts: Coordinate[][] = [];
  let currentPart: Coordinate[] = [];
  for (let i = 1; i < coordinates.length; i += 1) {
    const segment = clipSegmentToExtent(
      coordinates[i - 1],
      coordinates[i],
      extent,
    );
    if (!segment) {
      if (currentPart.length >= 2) parts.push(currentPart);
      currentPart = [];
      continue;
    }
    const [start, end] = segment;
    if (
      currentPart.length > 0 &&
      !sameCoordinate(currentPart[currentPart.length - 1], start)
    ) {
      if (currentPart.length >= 2) parts.push(currentPart);
      currentPart = [];
    }
    if (
      currentPart.length === 0 ||
      !sameCoordinate(currentPart[currentPart.length - 1], start)
    ) {
      currentPart.push(start);
    }
    if (!sameCoordinate(start, end)) {
      currentPart.push(end);
    }
  }
  if (currentPart.length >= 2) parts.push(currentPart);
  return parts;
};

export const coordinatesToLonLat = (
  coordinates: Coordinate[],
): [number, number][] =>
  coordinates.map((coordinate) => {
    const [lon, lat] = toLonLat(coordinate);
    return [lon, lat];
  });

const sphericalDistance = (
  start: [number, number],
  end: [number, number],
) => {
  const startLatitude = start[1] * DEGREES_TO_RADIANS;
  const endLatitude = end[1] * DEGREES_TO_RADIANS;
  const latitudeDelta = (end[1] - start[1]) * DEGREES_TO_RADIANS;
  const longitudeDelta = (end[0] - start[0]) * DEGREES_TO_RADIANS;
  const haversine =
    Math.sin(latitudeDelta / 2) ** 2 +
    Math.sin(longitudeDelta / 2) ** 2 *
      Math.cos(startLatitude) *
      Math.cos(endLatitude);
  const normalizedHaversine = Math.min(1, Math.max(0, haversine));
  return (
    2 *
    EARTH_RADIUS_KM *
    Math.atan2(
      Math.sqrt(normalizedHaversine),
      Math.sqrt(1 - normalizedHaversine),
    )
  );
};

const sphericalBearing = (
  start: [number, number],
  end: [number, number],
) => {
  const startLatitude = start[1] * DEGREES_TO_RADIANS;
  const endLatitude = end[1] * DEGREES_TO_RADIANS;
  const longitudeDelta = (end[0] - start[0]) * DEGREES_TO_RADIANS;
  return (
    Math.atan2(
      Math.sin(longitudeDelta) * Math.cos(endLatitude),
      Math.cos(startLatitude) * Math.sin(endLatitude) -
        Math.sin(startLatitude) *
          Math.cos(endLatitude) *
          Math.cos(longitudeDelta),
    ) * RADIANS_TO_DEGREES
  );
};

const sphericalDestination = (
  origin: [number, number],
  distance: number,
  bearing: number,
): [number, number] => {
  const longitude = origin[0] * DEGREES_TO_RADIANS;
  const latitude = origin[1] * DEGREES_TO_RADIANS;
  const bearingRadians = bearing * DEGREES_TO_RADIANS;
  const angularDistance = distance / EARTH_RADIUS_KM;
  const destinationLatitude = Math.asin(
    Math.sin(latitude) * Math.cos(angularDistance) +
      Math.cos(latitude) *
        Math.sin(angularDistance) *
        Math.cos(bearingRadians),
  );
  const destinationLongitude =
    longitude +
    Math.atan2(
      Math.sin(bearingRadians) *
        Math.sin(angularDistance) *
        Math.cos(latitude),
      Math.cos(angularDistance) -
        Math.sin(latitude) * Math.sin(destinationLatitude),
    );
  return [
    destinationLongitude * RADIANS_TO_DEGREES,
    destinationLatitude * RADIANS_TO_DEGREES,
  ];
};

const coordinateAtDistance = (
  path: [number, number][],
  cumulativeLengths: Float64Array,
  targetDistance: number,
): [number, number] => {
  let index = 1;
  while (
    index < cumulativeLengths.length &&
    cumulativeLengths[index] < targetDistance
  ) {
    index += 1;
  }
  if (index >= path.length) return path[path.length - 1];
  if (cumulativeLengths[index] === targetDistance) return path[index];

  const segmentStart = path[index - 1];
  const remainingDistance =
    targetDistance - cumulativeLengths[index - 1];
  return sphericalDestination(
    segmentStart,
    remainingDistance,
    sphericalBearing(segmentStart, path[index]),
  );
};

export const buildPipeFeatureFragments = (
  instance: TileFeatureInstance,
): PipeFeatureFragment[] => {
  const tileCoordinates = lineStringFromFlatCoordinates(
    instance.flatCoordinates,
    instance.stride,
  );
  return clipLineStringPartsToExtent(
    tileCoordinates,
    instance.tileExtent,
  ).flatMap((clippedCoordinates, partIndex) => {
    const path = coordinatesToLonLat(clippedCoordinates);
    if (path.length < 2) return [];

    const cumulativeLengths = new Float64Array(path.length);
    let fragmentLength = 0;
    for (let index = 1; index < path.length; index += 1) {
      fragmentLength += sphericalDistance(path[index - 1], path[index]);
      cumulativeLengths[index] = fragmentLength;
    }
    if (fragmentLength <= 0) return [];

    const timestamps = new Array<number>(path.length);
    timestamps[0] = 0;
    for (let index = 1; index < path.length; index += 1) {
      timestamps[index] =
        (cumulativeLengths[index] / fragmentLength) * 10;
    }

    const previousPoint = coordinateAtDistance(
      path,
      cumulativeLengths,
      fragmentLength * 0.49,
    );
    const position = coordinateAtDistance(
      path,
      cumulativeLengths,
      fragmentLength * 0.5,
    );
    const nextPoint = coordinateAtDistance(
      path,
      cumulativeLengths,
      fragmentLength * 0.51,
    );
    let angle = -sphericalBearing(previousPoint, nextPoint) + 90;
    if (angle < -90 || angle > 90) angle += 180;

    return [
      {
        instanceKey: `${instance.instanceKey}/${partIndex}`,
        id: instance.featureId,
        diameter: instance.properties.diameter || 0,
        length: instance.properties.length || fragmentLength * 1000,
        path,
        position,
        angle,
        timestamps,
        fragmentLength,
      },
    ];
  });
};

export class TileFeatureIndex {
  private readonly instancesByTile = new Map<
    TileKey,
    TileFeatureInstance[]
  >();
  private readonly registeredTiles = new Map<TileKey, RegisteredTile>();
  private readonly tileKeysByZoom = new Map<number, Set<TileKey>>();
  private nextRevision = 0;

  constructor(
    private readonly sourceKey: string,
    private readonly source: VectorTileSource,
  ) {
    this.scanLoadedTiles();
  }

  scanLoadedTiles() {
    const activeTileKeys = new Set<TileKey>();
    getLoadedVectorTiles(this.source).forEach((tile) => {
      const tileKey = this.registerTile(tile);
      if (tileKey) activeTileKeys.add(tileKey);
    });
    this.pruneMissingTiles(activeTileKeys);
  }

  registerTile(tile: any): TileKey | null {
    if (typeof tile?.getFeatures !== "function") return null;
    const coord = getTileCoord(tile);
    if (!coord) return null;
    const [z, x, y] = coord;
    const extent = getTileExtent(this.source, tile, z, x, y);
    if (!extent) return null;

    const tileKey = getTileKey(this.sourceKey, z, x, y);
    const tileRevision =
      typeof tile.getRevision === "function"
        ? tile.getRevision()
        : `${typeof tile.getState === "function" ? tile.getState() : ""}`;
    const registeredTile = this.registeredTiles.get(tileKey);
    if (
      registeredTile &&
      registeredTile.tile === tile &&
      registeredTile.sourceRevision === tileRevision
    ) {
      return tileKey;
    }

    const tileInstances: TileFeatureInstance[] = [];
    const renderFeatures = tile.getFeatures() ?? [];
    renderFeatures.forEach((renderFeature: any, featureOrdinal: number) => {
      const properties = getVectorTileFeatureProperties(renderFeature);
      const featureId = getVectorTileFeatureId(renderFeature);
      if (!featureId) return;
      const geometry = getFeatureGeometry(renderFeature);
      if (!geometry) return;
      const flatCoordinates = getFlatCoordinates(geometry);
      const stride = getStride(geometry);
      if (flatCoordinates.length < 2 || stride < 2) return;

      const instanceKey = getInstanceKey(
        this.sourceKey,
        z,
        x,
        y,
        featureOrdinal,
      );
      tileInstances.push({
        instanceKey,
        z,
        featureId,
        properties,
        geometryType: getGeometryType(geometry),
        tileExtent: extent,
        flatCoordinates,
        stride,
      });
    });

    if (tileInstances.length === 0) {
      this.instancesByTile.delete(tileKey);
      const zoomTileKeys = this.tileKeysByZoom.get(z);
      zoomTileKeys?.delete(tileKey);
      if (zoomTileKeys?.size === 0) this.tileKeysByZoom.delete(z);
    } else {
      this.instancesByTile.set(tileKey, tileInstances);
      const zoomTileKeys = this.tileKeysByZoom.get(z);
      if (zoomTileKeys) zoomTileKeys.add(tileKey);
      else this.tileKeysByZoom.set(z, new Set([tileKey]));
    }
    this.nextRevision += 1;
    this.registeredTiles.set(tileKey, {
      tile,
      sourceRevision: tileRevision,
      signatureRevision:
        tileRevision === "" ? `local-${this.nextRevision}` : tileRevision,
      z,
    });
    return tileKey;
  }

  private pruneMissingTiles(activeTileKeys: Set<TileKey>) {
    Array.from(this.registeredTiles.keys()).forEach((tileKey) => {
      if (!activeTileKeys.has(tileKey)) {
        const registration = this.registeredTiles.get(tileKey);
        this.instancesByTile.delete(tileKey);
        this.registeredTiles.delete(tileKey);
        if (registration) {
          const zoomTileKeys = this.tileKeysByZoom.get(registration.z);
          zoomTileKeys?.delete(tileKey);
          if (zoomTileKeys?.size === 0) {
            this.tileKeysByZoom.delete(registration.z);
          }
        }
      }
    });
  }

  getSnapshot(map: OlMap | undefined, zoom: number): TileFeatureSnapshot {
    const tileGrid = getTileGrid(this.source, map);
    const resolution = map?.getView().getResolution();
    const targetZ =
      tileGrid && resolution !== undefined
        ? tileGrid.getZForResolution(resolution, (this.source as any).zDirection)
        : Math.max(0, Math.round(zoom));
    const viewExtent = map?.getView().calculateExtent(map.getSize());
    const activeTileKeys = Array.from(
      this.tileKeysByZoom.get(targetZ) ?? [],
    )
      .filter((tileKey) => {
        const tileInstances = this.instancesByTile.get(tileKey);
        return (
          tileInstances &&
          tileInstances.length > 0 &&
          (!viewExtent ||
            intersects(tileInstances[0].tileExtent, viewExtent))
        );
      })
      .sort();
    const instances = activeTileKeys
      .flatMap((tileKey) => this.instancesByTile.get(tileKey) ?? [])
      .sort((a, b) => a.instanceKey.localeCompare(b.instanceKey));

    const instancesById = new Map<string, TileFeatureInstance[]>();
    instances.forEach((instance) => {
      const featureInstances = instancesById.get(instance.featureId);
      if (featureInstances) featureInstances.push(instance);
      else instancesById.set(instance.featureId, [instance]);
    });

    const signature = `${targetZ}:${activeTileKeys
      .map((tileKey) => {
        const registration = this.registeredTiles.get(tileKey);
        return `${tileKey}@${registration?.signatureRevision ?? "unknown"}`;
      })
      .join("|")}`;
    return { signature, instancesById, instances };
  }
}
