// loaders.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

// Forked from https://github.com/derhuerst/parse-gml-polygon/blob/master/index.js
// under ISC license

/* eslint-disable no-continue, default-case */

import type {
  // GeoJSON,
  // Feature,
  // FeatureCollection,
  Geometry,
  Position
  // GeoJsonProperties,
  // Point,
  // MultiPoint,
  // LineString,
  // MultiLineString,
  // Polygon,
  // MultiPolygon,
  // GeometryCollection
} from '@loaders.gl/schema';

import {XMLLoader} from '@loaders.gl/xml';
import {deepStrictEqual} from './deep-strict-equal';
import rewind from '@turf/rewind';

function noTransform(...coords) {
  return coords;
}

export type {Geometry};

export type ParseGMLOptions = {
  transformCoords?: Function;
  stride?: 2 | 3 | 4;
};

export type ParseGMLContext = {
  srsDimension?: number;
  [key: string]: any;
};

/**
 * Parses a typed data structure from raw XML for GML features
 * @note Error handlings is fairly weak
 */
export function parseGML(text: string, options) {
  // GeoJSON | null {
  const parsedXML = XMLLoader.parseTextSync?.(text, options);

  options = {transformCoords: noTransform, stride: 2, ...options};
  const context = createChildContext(parsedXML, options, {});

  return parseGMLToGeometry(parsedXML, options, context);
}

/** Parse a GeoJSON geometry from GML XML */
export function parseGMLToGeometry(
  inputXML: any,
  options: ParseGMLOptions,
  context: ParseGMLContext
): Geometry | null {
  const childContext = createChildContext(inputXML, options, context);

  let geometry: Geometry | null = null;

  const [name, xml] = getFirstKeyValue(inputXML);

  switch (name) {
    // case 'gml:MultiPoint':
    //   geometry = {
    //     type: 'MultiPoint',
    //     coordinates: parseMultiPoint(xml, options, childContext)
    //   };
    //   break;

    case 'gml:LineString':
      geometry = {
        type: 'LineString',
        coordinates: parseLinearRingOrLineString(xml, options, childContext)
      };
      break;

      // case 'gml:MultiLineString':
      //   geometry = {
      //     type: 'MultiLineString',
      //     coordinates: parseMultiLineString(xml, options, childContext)
      //   };
      //   break;

    case 'gml:Polygon':
    case 'gml:Rectangle':
      geometry = {
        type: 'Polygon',
        coordinates: parsePolygonOrRectangle(xml, options, childContext)
      };
      break;
    case 'gml:Surface':
      geometry = {
        type: 'MultiPolygon',
        coordinates: parseSurface(xml, options, childContext)
      };
      break;
    case 'gml:MultiSurface':
      geometry = {
        type: 'MultiPolygon',
        coordinates: parseMultiSurface(xml, options, childContext)
      };
      break;

    default:
      return null;
  }

  // todo
  return rewind(geometry, {mutate: true});
}

/** Parse a list of coordinates from a string */
function parseCoords(s: string, options: ParseGMLOptions, context: ParseGMLContext): Position[] {
  const stride = context.srsDimension || options.stride || 2;

  // Handle white space
  const coords = s.replace(/\s+/g, ' ').trim().split(' ');

  if (coords.length === 0 || coords.length % stride !== 0) {
    throw new Error(`invalid coordinates list (stride ${stride})`);
  }

  const points: Position[] = [];
  for (let i = 0; i < coords.length - 1; i += stride) {
    const point = coords.slice(i, i + stride).map(parseFloat);
    points.push(options.transformCoords?.(...point) || point);
  }

  return points;
}

export function parsePosList(xml: any, options: ParseGMLOptions, context: ParseGMLContext) {
  const childContext = createChildContext(xml, options, context);

  const coords = textOf(xml);
  if (!coords) {
    throw new Error('invalid gml:posList element');
  }

  return parseCoords(coords, options, childContext);
}

export function parsePos(xml: any, options: ParseGMLOptions, context: ParseGMLContext): Position {
  const childContext = createChildContext(xml, options, context);

  const coords = textOf(xml);
  if (!coords) {
    throw new Error('invalid gml:pos element');
  }

  const points = parseCoords(coords, options, childContext);
  if (points.length !== 1) {
    throw new Error('gml:pos must have 1 point');
  }
  return points[0];
}

export function parsePoint(xml: any, options: ParseGMLOptions, context: ParseGMLContext): number[] {
  const childContext = createChildContext(xml, options, context);

  // TODO AV: Parse other gml:Point options
  const pos = findIn(xml, 'gml:pos');
  if (!pos) {
    throw new Error('invalid gml:Point element, expected a gml:pos subelement');
  }
  return parsePos(pos, options, childContext);
}

export function parseLinearRingOrLineString(
  xml: any,
  options: ParseGMLOptions,
  context: ParseGMLContext
): Position[] {
  // or a LineStringSegment
  const childContext = createChildContext(xml, options, context);

  let points: Position[] = [];

  const posList = findIn(xml, 'gml:posList');
  if (posList) {
    points = parsePosList(posList, options, childContext);
  } else {
    for (const [childName, childXML] of Object.entries(xml)) {
      switch (childName) {
        case 'gml:Point':
          points.push(parsePoint(childXML, options, childContext));
          break;
        case 'gml:pos':
          points.push(parsePos(childXML, options, childContext));
          break;
        default:
          continue;
      }
    }
  }

  if (points.length === 0) {
    throw new Error(`${xml.name} must have > 0 points`);
  }
  return points;
}

export function parseCurveSegments(
  xml: any,
  options: ParseGMLOptions,
  context: ParseGMLContext
): Position[] {
  const points: Position[] = [];

  for (const [childName, childXML] of Object.entries(xml)) {
    switch (childName) {
      case 'gml:LineStringSegment':
        const points2 = parseLinearRingOrLineString(childXML, options, context);

        // remove overlapping
        const end = points[points.length - 1];
        const start = points2[0];
        if (end && start && deepStrictEqual(end, start)) {
          points2.shift();
        }

        points.push(...points2);
        break;
      default:
        continue;
    }
  }

  if (points.length === 0) {
    throw new Error('gml:Curve > gml:segments must have > 0 points');
  }
  return points;
}

export function parseRing(
  xml: any,
  options: ParseGMLOptions,
  context: ParseGMLContext
): Position[] {
  const childContext = createChildContext(xml, options, context);

  const points: Position[] = [];

  for (const [childName, childXML] of Object.entries(xml)) {
    switch (childName) {
      case 'gml:curveMember':
        let points2;

        const lineString = findIn(childXML, 'gml:LineString');
        if (lineString) {
          points2 = parseLinearRingOrLineString(lineString, options, childContext);
        } else {
          const segments = findIn(childXML, 'gml:Curve', 'gml:segments');
          if (!segments) {
            throw new Error(`invalid ${childName} element`);
          }

          points2 = parseCurveSegments(segments, options, childContext);
        }

        // remove overlapping
        const end = points[points.length - 1];
        const start = points2[0];
        if (end && start && deepStrictEqual(end, start)) {
          points2.shift();
        }

        points.push(...points2);

        break;
    }
  }

  if (points.length < 4) {
    throw new Error(`${xml.name} must have >= 4 points`);
  }
  return points;
}

export function parseExteriorOrInterior(
  xml: any,
  options: ParseGMLOptions,
  context: ParseGMLContext
): Position[] {
  const linearRing = findIn(xml, 'gml:LinearRing');
  if (linearRing) {
    return parseLinearRingOrLineString(linearRing, options, context);
  }

  const ring = findIn(xml, 'gml:Ring');
  if (!ring) {
    throw new Error(`invalid ${xml.name} element`);
  }

  return parseRing(ring, options, context);
}

export function parsePolygonOrRectangle(
  xml: any,
  options: ParseGMLOptions,
  context: ParseGMLContext
): Position[][] {
  // or PolygonPatch
  const childContext = createChildContext(xml, options, context);

  const exterior = findIn(xml, 'gml:exterior');
  if (!exterior) {
    throw new Error(`invalid ${xml.name} element`);
  }

  const pointLists: Position[][] = [parseExteriorOrInterior(exterior, options, childContext)];

  for (const [childName, childXML] of Object.entries(xml)) {
    switch (childName) {
      case 'gml:interior':
        pointLists.push(parseExteriorOrInterior(childXML, options, childContext));
        break;
    }
  }

  return pointLists;
}

export function parseSurface(
  xml: any,
  options: ParseGMLOptions,
  context: ParseGMLContext
): Position[][][] {
  const childContext = createChildContext(xml, options, context);

  const patches = findIn(xml, 'gml:patches');
  if (!patches) {
    throw new Error(`invalid ${xml.name} element`);
  }

  const polygons: Position[][][] = [];
  for (const [childName, childXML] of Object.entries(xml)) {
    switch (childName) {
      case 'gml:PolygonPatch':
      case 'gml:Rectangle':
        polygons.push(parsePolygonOrRectangle(childXML, options, childContext));
        break;

      default:
        continue;
    }
  }

  if (polygons.length === 0) {
    throw new Error(`${xml.name} must have > 0 polygons`);
  }

  return polygons;
}

export function parseCompositeSurface(
  xml: any,
  options: ParseGMLOptions,
  context: ParseGMLContext
): Position[][][] {
  const childContext = createChildContext(xml, options, context);

  const polygons: Position[][][] = [];
  for (const [childName, childXML] of Object.entries(xml)) {
    switch (childName) {
      case 'gml:surfaceMember':
      case 'gml:surfaceMembers':
        const [c2Name, c2Xml] = getFirstKeyValue(childXML);
        switch (c2Name) {
          case 'gml:Surface':
            polygons.push(...parseSurface(c2Xml, options, childContext));
            break;
          case 'gml:Polygon':
            polygons.push(parsePolygonOrRectangle(c2Xml, options, childContext));
            break;
        }
        break;
    }
  }

  if (polygons.length === 0) {
    throw new Error(`${xml.name} must have > 0 polygons`);
  }
  return polygons;
}

export function parseMultiSurface(
  xml: any,
  options: ParseGMLOptions,
  context: ParseGMLContext
): Position[][][] {
  let el = xml;

  const surfaceMembers = findIn(xml, 'gml:LinearRing');
  if (surfaceMembers) {
    el = surfaceMembers;
  }

  const polygons: Position[][][] = [];
  for (const [childName, childXML] of Object.entries(el)) {
    switch (childName) {
      case 'gml:Surface':
        const polygons2 = parseSurface(childXML, options, context);
        polygons.push(...polygons2);
        break;
      case 'gml:surfaceMember':
        const polygons3 = parseSurfaceMember(childXML, options, context);
        polygons.push(...polygons3);
        break;

      case 'gml:surfaceMembers':
        const polygonXML = findIn(childXML, 'gml:Polygon');
        for (const surfaceMemberXML of polygonXML as []) {
          const polygons3 = parseSurfaceMember(surfaceMemberXML, options, context);
          polygons.push(...polygons3);
        }
        break;
    }
  }

  if (polygons.length === 0) {
    throw new Error(`${xml.name} must have > 0 polygons`);
  }

  return polygons;
}

function parseSurfaceMember(
  xml: any,
  options: ParseGMLOptions,
  context: ParseGMLContext
): Position[][][] {
  const [childName, childXml] = getFirstKeyValue(xml);
  switch (childName) {
    case 'gml:CompositeSurface':
      return parseCompositeSurface(childXml, options, context);
    case 'gml:Surface':
      return parseSurface(childXml, options, context);
    case 'gml:Polygon':
      return [parsePolygonOrRectangle(childXml, options, context)];
  }
  throw new Error(`${childName} must have polygons`);
}

// Helpers

function textOf(el: any): string {
  if (typeof el !== 'string') {
    throw new Error('expected string');
  }
  return el;
}

function findIn(root: any, ...tags: string[]): any {
  let el = root;
  for (const tag of tags) {
    const child = el[tag];
    if (!child) {
      return null;
    }
    el = child;
  }
  return el;
}

/** @returns the first [key, value] pair in an object, or ['', null] if empty object */
function getFirstKeyValue(object: any): [string, any] {
  if (object && typeof object === 'object') {
    for (const [key, value] of Object.entries(object)) {
      return [key, value];
    }
  }
  return ['', null];
}

/** A bit heavyweight for just tracking dimension? */
function createChildContext(xml, options, context): ParseGMLContext {
  const srsDimensionAttribute = xml.attributes && xml.attributes.srsDimension;

  if (srsDimensionAttribute) {
    const srsDimension = parseInt(srsDimensionAttribute);
    if (Number.isNaN(srsDimension) || srsDimension <= 0) {
      throw new Error(
        `invalid srsDimension attribute value "${srsDimensionAttribute}", expected a positive integer`
      );
    }

    const childContext = Object.create(context);
    childContext.srsDimension = srsDimension;
    return childContext;
  }

  return context;
}
