import Cartesian3 from "../Core/Cartesian3.js";
import Check from "../Core/Check.js";
import Color from "../Core/Color.js";
import combine from "../Core/combine.js";
import ComponentDatatype from "../Core/ComponentDatatype.js";
import defined from "../Core/defined.js";
import getJsonFromTypedArray from "../Core/getJsonFromTypedArray.js";
import oneTimeWarning from "../Core/oneTimeWarning.js";
import RuntimeError from "../Core/RuntimeError.js";
import AttributeType from "./AttributeType.js";
import Cesium3DTileFeatureTable from "./Cesium3DTileFeatureTable.js";
import VertexAttributeSemantic from "./VertexAttributeSemantic.js";

/**
 * Handles parsing of a Point Cloud
 *
 * @namespace PntsParser
 * @private
 */
const PntsParser = {};

const sizeOfUint32 = Uint32Array.BYTES_PER_ELEMENT;

/**
 * Parses the contents of a {@link https://github.com/CesiumGS/3d-tiles/tree/main/specification/TileFormats/PointCloud|Point Cloud}.
 *
 * @private
 *
 * @param {*} arrayBuffer The array buffer containing the pnts
 * @param {*} [byteOffset=0] The byte offset of the beginning of the pnts in the array buffer
 * @returns {object} An object containing a parsed representation of the point cloud
 */
PntsParser.parse = function (arrayBuffer, byteOffset) {
  byteOffset = byteOffset ?? 0;
  //>>includeStart('debug', pragmas.debug);
  Check.defined("arrayBuffer", arrayBuffer);
  //>>includeEnd('debug');

  const uint8Array = new Uint8Array(arrayBuffer);
  const view = new DataView(arrayBuffer);
  byteOffset += sizeOfUint32; // Skip magic

  const version = view.getUint32(byteOffset, true);
  if (version !== 1) {
    throw new RuntimeError(
      `Only Point Cloud tile version 1 is supported.  Version ${version} is not.`,
    );
  }
  byteOffset += sizeOfUint32;

  // Skip byteLength
  byteOffset += sizeOfUint32;

  const featureTableJsonByteLength = view.getUint32(byteOffset, true);
  if (featureTableJsonByteLength === 0) {
    throw new RuntimeError(
      "Feature table must have a byte length greater than zero",
    );
  }
  byteOffset += sizeOfUint32;

  const featureTableBinaryByteLength = view.getUint32(byteOffset, true);
  byteOffset += sizeOfUint32;

  const batchTableJsonByteLength = view.getUint32(byteOffset, true);
  byteOffset += sizeOfUint32;
  const batchTableBinaryByteLength = view.getUint32(byteOffset, true);
  byteOffset += sizeOfUint32;

  const featureTableJson = getJsonFromTypedArray(
    uint8Array,
    byteOffset,
    featureTableJsonByteLength,
  );
  byteOffset += featureTableJsonByteLength;

  const featureTableBinary = new Uint8Array(
    arrayBuffer,
    byteOffset,
    featureTableBinaryByteLength,
  );
  byteOffset += featureTableBinaryByteLength;

  // Get the batch table JSON and binary
  let batchTableJson;
  let batchTableBinary;
  if (batchTableJsonByteLength > 0) {
    // Has a batch table JSON
    batchTableJson = getJsonFromTypedArray(
      uint8Array,
      byteOffset,
      batchTableJsonByteLength,
    );
    if (Object.keys(batchTableJson).length === 0) {
      batchTableJson = undefined;
    }
    byteOffset += batchTableJsonByteLength;

    if (batchTableBinaryByteLength > 0) {
      // Has a batch table binary
      batchTableBinary = new Uint8Array(
        arrayBuffer,
        byteOffset,
        batchTableBinaryByteLength,
      );
      // Copy the batchTableBinary section and let the underlying ArrayBuffer be freed
      batchTableBinary = new Uint8Array(batchTableBinary);
      byteOffset += batchTableBinaryByteLength;
    }
  }

  const featureTable = new Cesium3DTileFeatureTable(
    featureTableJson,
    featureTableBinary,
  );

  const pointsLength = featureTable.getGlobalProperty("POINTS_LENGTH");
  featureTable.featuresLength = pointsLength;

  if (!defined(pointsLength)) {
    throw new RuntimeError(
      "Feature table global property: POINTS_LENGTH must be defined",
    );
  }

  let rtcCenter = featureTable.getGlobalProperty(
    "RTC_CENTER",
    ComponentDatatype.FLOAT,
    3,
  );
  if (defined(rtcCenter)) {
    rtcCenter = Cartesian3.unpack(rtcCenter);
  }

  // Start with the draco compressed properties and add in uncompressed
  // properties.
  const parsedContent = parseDracoProperties(featureTable, batchTableJson);
  parsedContent.rtcCenter = rtcCenter;
  parsedContent.pointsLength = pointsLength;

  if (!parsedContent.hasPositions) {
    const positions = parsePositions(featureTable);
    parsedContent.positions = positions;
    parsedContent.hasPositions =
      parsedContent.hasPositions || defined(positions);
  }

  if (!parsedContent.hasPositions) {
    throw new RuntimeError(
      "Either POSITION or POSITION_QUANTIZED must be defined.",
    );
  }

  if (!parsedContent.hasNormals) {
    const normals = parseNormals(featureTable);
    parsedContent.normals = normals;
    parsedContent.hasNormals = parsedContent.hasNormals || defined(normals);
  }

  if (!parsedContent.hasColors) {
    const colors = parseColors(featureTable);
    parsedContent.colors = colors;
    parsedContent.hasColors = parsedContent.hasColors || defined(colors);
    parsedContent.hasConstantColor = defined(parsedContent.constantColor);
    parsedContent.isTranslucent = defined(colors) && colors.isTranslucent;
  }

  if (!parsedContent.hasBatchIds) {
    const batchIds = parseBatchIds(featureTable);
    parsedContent.batchIds = batchIds;
    parsedContent.hasBatchIds = parsedContent.hasBatchIds || defined(batchIds);
  }

  if (parsedContent.hasBatchIds) {
    const batchLength = featureTable.getGlobalProperty("BATCH_LENGTH");
    if (!defined(batchLength)) {
      throw new RuntimeError(
        "Global property: BATCH_LENGTH must be defined when BATCH_ID is defined.",
      );
    }
    parsedContent.batchLength = batchLength;
  }

  if (defined(batchTableJson) || defined(batchTableBinary)) {
    parsedContent.batchTableJson = batchTableJson;
    parsedContent.batchTableBinary = batchTableBinary;
  }

  // Handle the case that binary body references are contained in the
  // batch table without a batch table binary being present
  removeInvalidBinaryBodyReferences(parsedContent);

  return parsedContent;
};

/**
 * Remove all invalid binary body references from the batch table
 * JSON of the given parsed content.
 *
 * This is a workaround for gracefully handling the invalid PNTS
 * files that may have been created by the point cloud tiler.
 * See https://github.com/CesiumGS/cesium/issues/12872
 *
 * When the batch table JSON is undefined, nothing will be done.
 * When the batch table binary is defined, nothing will be done
 * (assuming that any binary body references are valid - this is
 * not checked here).
 *
 * Otherwise, this will remove all binary body references from the
 * batch table JSON that are not resolved from draco via the
 * `parsedContent.draco.batchTableProperties`.
 *
 * If any (invalid) binary body reference is found (and removed),
 * a one-time warning will be printed.
 *
 * @param {object} parsedContent The parsed content
 */
function removeInvalidBinaryBodyReferences(parsedContent) {
  const batchTableJson = parsedContent.batchTableJson;
  if (!defined(batchTableJson)) {
    return;
  }
  const batchTableBinary = parsedContent.batchTableBinary;
  if (defined(batchTableBinary)) {
    return;
  }
  const dracoBatchTablePropertyNames = Object.keys(
    parsedContent.draco?.batchTableProperties ?? {},
  );

  // Collect the names of all binary body references (identified
  // by the property having a `byteOffset`) that have not been
  // resolved via the parsedContent.draco.batchTableProperties
  const invalidBinaryBodyReferenceNames = [];
  for (const name of Object.keys(batchTableJson)) {
    const property = batchTableJson[name];
    const byteOffset = property.byteOffset;
    if (defined(byteOffset)) {
      if (!dracoBatchTablePropertyNames.includes(name)) {
        invalidBinaryBodyReferenceNames.push(name);
      }
    }
  }

  // If there have been invalid binary body references, print
  // a one-time warning for each of them, and delete them.
  for (const name of invalidBinaryBodyReferenceNames) {
    oneTimeWarning(
      `PntsParser-invalidBinaryBodyReference`,
      `The point cloud data contained a binary property ${name} that could not be resolved - skipping`,
    );
    delete batchTableJson[name];
  }
}

function parseDracoProperties(featureTable, batchTableJson) {
  const featureTableJson = featureTable.json;
  let dracoBuffer;
  let dracoFeatureTableProperties;
  let dracoBatchTableProperties;

  const featureTableDraco = defined(featureTableJson.extensions)
    ? featureTableJson.extensions["3DTILES_draco_point_compression"]
    : undefined;
  const batchTableDraco =
    defined(batchTableJson) && defined(batchTableJson.extensions)
      ? batchTableJson.extensions["3DTILES_draco_point_compression"]
      : undefined;

  if (defined(batchTableDraco)) {
    dracoBatchTableProperties = batchTableDraco.properties;
  }

  let hasPositions;
  let hasColors;
  let hasNormals;
  let hasBatchIds;
  let isTranslucent;
  if (defined(featureTableDraco)) {
    dracoFeatureTableProperties = featureTableDraco.properties;
    const dracoByteOffset = featureTableDraco.byteOffset;
    const dracoByteLength = featureTableDraco.byteLength;
    if (
      !defined(dracoFeatureTableProperties) ||
      !defined(dracoByteOffset) ||
      !defined(dracoByteLength)
    ) {
      throw new RuntimeError(
        "Draco properties, byteOffset, and byteLength must be defined",
      );
    }
    dracoBuffer = featureTable.buffer.slice(
      dracoByteOffset,
      dracoByteOffset + dracoByteLength,
    );
    hasPositions = defined(dracoFeatureTableProperties.POSITION);
    hasColors =
      defined(dracoFeatureTableProperties.RGB) ||
      defined(dracoFeatureTableProperties.RGBA);
    hasNormals = defined(dracoFeatureTableProperties.NORMAL);
    hasBatchIds = defined(dracoFeatureTableProperties.BATCH_ID);
    isTranslucent = defined(dracoFeatureTableProperties.RGBA);
  }

  let draco;
  if (defined(dracoBuffer)) {
    draco = {
      buffer: dracoBuffer,
      featureTableProperties: dracoFeatureTableProperties,
      batchTableProperties: dracoBatchTableProperties,
      properties: combine(
        dracoFeatureTableProperties,
        dracoBatchTableProperties,
      ),
      dequantizeInShader: true,
    };
  }

  return {
    draco: draco,
    hasPositions: hasPositions,
    hasColors: hasColors,
    isTranslucent: isTranslucent,
    hasNormals: hasNormals,
    hasBatchIds: hasBatchIds,
  };
}

function parsePositions(featureTable) {
  const featureTableJson = featureTable.json;

  let positions;
  if (defined(featureTableJson.POSITION)) {
    positions = featureTable.getPropertyArray(
      "POSITION",
      ComponentDatatype.FLOAT,
      3,
    );

    return {
      name: VertexAttributeSemantic.POSITION,
      semantic: VertexAttributeSemantic.POSITION,
      typedArray: positions,
      isQuantized: false,
      componentDatatype: ComponentDatatype.FLOAT,
      type: AttributeType.VEC3,
    };
  } else if (defined(featureTableJson.POSITION_QUANTIZED)) {
    positions = featureTable.getPropertyArray(
      "POSITION_QUANTIZED",
      ComponentDatatype.UNSIGNED_SHORT,
      3,
    );

    const quantizedVolumeScale = featureTable.getGlobalProperty(
      "QUANTIZED_VOLUME_SCALE",
      ComponentDatatype.FLOAT,
      3,
    );
    if (!defined(quantizedVolumeScale)) {
      throw new RuntimeError(
        "Global property: QUANTIZED_VOLUME_SCALE must be defined for quantized positions.",
      );
    }
    const quantizedRange = (1 << 16) - 1;

    const quantizedVolumeOffset = featureTable.getGlobalProperty(
      "QUANTIZED_VOLUME_OFFSET",
      ComponentDatatype.FLOAT,
      3,
    );
    if (!defined(quantizedVolumeOffset)) {
      throw new RuntimeError(
        "Global property: QUANTIZED_VOLUME_OFFSET must be defined for quantized positions.",
      );
    }

    return {
      name: VertexAttributeSemantic.POSITION,
      semantic: VertexAttributeSemantic.POSITION,
      typedArray: positions,
      isQuantized: true,
      componentDatatype: ComponentDatatype.FLOAT,
      type: AttributeType.VEC3,
      quantizedRange: quantizedRange,
      quantizedVolumeOffset: Cartesian3.unpack(quantizedVolumeOffset),
      quantizedVolumeScale: Cartesian3.unpack(quantizedVolumeScale),
      quantizedComponentDatatype: ComponentDatatype.UNSIGNED_SHORT,
      quantizedType: AttributeType.VEC3,
    };
  }
}

function parseColors(featureTable) {
  const featureTableJson = featureTable.json;

  let colors;
  if (defined(featureTableJson.RGBA)) {
    colors = featureTable.getPropertyArray(
      "RGBA",
      ComponentDatatype.UNSIGNED_BYTE,
      4,
    );
    return {
      name: VertexAttributeSemantic.COLOR,
      semantic: VertexAttributeSemantic.COLOR,
      setIndex: 0,
      typedArray: colors,
      componentDatatype: ComponentDatatype.UNSIGNED_BYTE,
      type: AttributeType.VEC4,
      normalized: true,
      isRGB565: false,
      isTranslucent: true,
    };
  } else if (defined(featureTableJson.RGB)) {
    colors = featureTable.getPropertyArray(
      "RGB",
      ComponentDatatype.UNSIGNED_BYTE,
      3,
    );
    return {
      name: "COLOR",
      semantic: VertexAttributeSemantic.COLOR,
      setIndex: 0,
      typedArray: colors,
      componentDatatype: ComponentDatatype.UNSIGNED_BYTE,
      type: AttributeType.VEC3,
      normalized: true,
      isRGB565: false,
      isTranslucent: false,
    };
  } else if (defined(featureTableJson.RGB565)) {
    colors = featureTable.getPropertyArray(
      "RGB565",
      ComponentDatatype.UNSIGNED_SHORT,
      1,
    );
    return {
      name: "COLOR",
      semantic: VertexAttributeSemantic.COLOR,
      setIndex: 0,
      typedArray: colors,
      // These settings are for the Model implementation
      // which decodes on the CPU and uploads a VEC3 of float colors.
      // PointCloud does the decoding on the GPU so uploads a
      // UNSIGNED_SHORT instead.
      componentDatatype: ComponentDatatype.FLOAT,
      type: AttributeType.VEC3,
      normalized: false,
      isRGB565: true,
      isTranslucent: false,
    };
  } else if (defined(featureTableJson.CONSTANT_RGBA)) {
    const constantRGBA = featureTable.getGlobalProperty(
      "CONSTANT_RGBA",
      ComponentDatatype.UNSIGNED_BYTE,
      4,
    );

    const alpha = constantRGBA[3];
    const constantColor = Color.fromBytes(
      constantRGBA[0],
      constantRGBA[1],
      constantRGBA[2],
      alpha,
    );

    const isTranslucent = alpha < 255;
    return {
      name: VertexAttributeSemantic.COLOR,
      semantic: VertexAttributeSemantic.COLOR,
      setIndex: 0,
      constantColor: constantColor,
      componentDatatype: ComponentDatatype.FLOAT,
      type: AttributeType.VEC4,
      isQuantized: false,
      isTranslucent: isTranslucent,
    };
  }

  return undefined;
}

function parseNormals(featureTable) {
  const featureTableJson = featureTable.json;
  let normals;
  if (defined(featureTableJson.NORMAL)) {
    normals = featureTable.getPropertyArray(
      "NORMAL",
      ComponentDatatype.FLOAT,
      3,
    );
    return {
      name: VertexAttributeSemantic.NORMAL,
      semantic: VertexAttributeSemantic.NORMAL,
      typedArray: normals,
      octEncoded: false,
      octEncodedZXY: false,
      componentDatatype: ComponentDatatype.FLOAT,
      type: AttributeType.VEC3,
    };
  } else if (defined(featureTableJson.NORMAL_OCT16P)) {
    normals = featureTable.getPropertyArray(
      "NORMAL_OCT16P",
      ComponentDatatype.UNSIGNED_BYTE,
      2,
    );
    const quantizationBits = 8;
    return {
      name: VertexAttributeSemantic.NORMAL,
      semantic: VertexAttributeSemantic.NORMAL,
      typedArray: normals,
      octEncoded: true,
      octEncodedZXY: false,
      quantizedRange: (1 << quantizationBits) - 1,
      quantizedType: AttributeType.VEC2,
      quantizedComponentDatatype: ComponentDatatype.UNSIGNED_BYTE,
      componentDatatype: ComponentDatatype.FLOAT,
      type: AttributeType.VEC3,
    };
  }

  return undefined;
}

function parseBatchIds(featureTable) {
  const featureTableJson = featureTable.json;
  if (defined(featureTableJson.BATCH_ID)) {
    const batchIds = featureTable.getPropertyArray(
      "BATCH_ID",
      ComponentDatatype.UNSIGNED_SHORT,
      1,
    );
    return {
      name: VertexAttributeSemantic.FEATURE_ID,
      semantic: VertexAttributeSemantic.FEATURE_ID,
      setIndex: 0,
      typedArray: batchIds,
      componentDatatype: ComponentDatatype.fromTypedArray(batchIds),
      type: AttributeType.SCALAR,
    };
  }

  return undefined;
}

export default PntsParser;
