import { serialize, deserialize } from '@ungap/structured-clone'
import { vec3, vec4 } from 'gl-matrix'
import { NVUtilities } from './nvutilities.js'
import { ImageFromUrlOptions, NVIMAGE_TYPE, NVImage } from './nvimage/index.js'
import { MeshType, NVMesh } from './nvmesh.js'
import { NVLabel3D } from './nvlabel.js'
import { NVConnectome } from './nvconnectome.js'
import { log } from './logger.js'

/**
 * Slice Type
 * @ignore
 */
export enum SLICE_TYPE {
  AXIAL = 0,
  CORONAL = 1,
  SAGITTAL = 2,
  MULTIPLANAR = 3,
  RENDER = 4
}

export enum SHOW_RENDER {
  NEVER = 0,
  ALWAYS = 1,
  AUTO = 2
}

/**
 * Multi-planar layout
 * @ignore
 */
export enum MULTIPLANAR_TYPE {
  AUTO = 0,
  COLUMN = 1,
  GRID = 2,
  ROW = 3
}

/**
 * Drag mode
 * @ignore
 */
export enum DRAG_MODE {
  none = 0,
  contrast = 1,
  measurement = 2,
  pan = 3,
  slicer3D = 4,
  callbackOnly = 5,
  roiSelection = 6
}

export enum DRAG_MODE_SECONDARY {
  none = 0,
  contrast = 1,
  measurement = 2,
  pan = 3,
  slicer3D = 4,
  callbackOnly = 5,
  roiSelection = 6
}

export enum DRAG_MODE_PRIMARY {
  crosshair = 0,
  windowing = 1
}

export enum COLORMAP_TYPE {
  MIN_TO_MAX = 0,
  ZERO_TO_MAX_TRANSPARENT_BELOW_MIN = 1,
  ZERO_TO_MAX_TRANSLUCENT_BELOW_MIN = 2
}

// make mutable type
type Mutable<T> = {
  -readonly [P in keyof T]: T[P]
}

/**
 * NVConfigOptions
 */
export type NVConfigOptions = {
  // 0 for no text, fraction of canvas min(height,width)
  textHeight: number
  // 0 for no colorbars, fraction of Nifti j dimension
  colorbarHeight: number
  // 0 for no crosshairs
  crosshairWidth: number
  crosshairWidthUnit: 'voxels' | 'mm' | 'percent'
  crosshairGap: number
  rulerWidth: number
  show3Dcrosshair: boolean
  backColor: number[]
  crosshairColor: number[]
  fontColor: Float32List
  selectionBoxColor: number[]
  clipPlaneColor: number[]
  clipThick: number
  clipVolumeLow: number[]
  clipVolumeHigh: number[]
  rulerColor: number[]
  // x axis margin around color bar, clip space coordinates
  colorbarMargin: number
  // if true do not calculate cal_min or cal_max if set in image header. If false, always calculate display intensity range.
  trustCalMinMax: boolean
  // keyboard short cut to activate the clip plane
  clipPlaneHotKey: string
  // keyboard shortcut to switch view modes
  viewModeHotKey: string
  doubleTouchTimeout: number
  longTouchTimeout: number
  // default debounce time used in keyup listeners
  keyDebounceTime: number
  isNearestInterpolation: boolean
  atlasOutline: number
  isRuler: boolean
  isColorbar: boolean
  isOrientCube: boolean
  tileMargin: number
  multiplanarPadPixels: number
  // @deprecated
  multiplanarForceRender: boolean
  multiplanarEqualSize: boolean
  multiplanarShowRender: SHOW_RENDER
  isRadiologicalConvention: boolean
  // string to allow infinity
  meshThicknessOn2D: number | string
  dragMode: DRAG_MODE | DRAG_MODE_SECONDARY
  dragModePrimary: DRAG_MODE_PRIMARY
  yoke3Dto2DZoom: boolean
  isDepthPickMesh: boolean
  isCornerOrientationText: boolean
  heroImageFraction: number
  heroSliceType: SLICE_TYPE
  // sagittal slices can have Y+ going left or right
  sagittalNoseLeft: boolean
  isSliceMM: boolean
  // V1 image overlays can show vectors as per-pixel lines
  isV1SliceShader: boolean
  forceDevicePixelRatio: number
  logLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent'
  loadingText: string
  isForceMouseClickToVoxelCenters: boolean
  dragAndDropEnabled: boolean
  // drawing disabled by default
  drawingEnabled: boolean
  // sets drawing color. see "drawPt"
  penValue: number
  // does a voxel have 6 (face), 18 (edge) or 26 (corner) neighbors
  floodFillNeighbors: number
  isFilledPen: boolean
  thumbnail: string
  maxDrawUndoBitmaps: number
  sliceType: SLICE_TYPE
  isAntiAlias: boolean | null
  isAdditiveBlend: boolean
  // TODO all following fields were previously not included in the typedef
  // Allow canvas width and height to resize (false for fixed size)
  isResizeCanvas: boolean
  meshXRay: number
  limitFrames4D: number
  // if a document has labels the default is to show them
  showLegend: boolean
  legendBackgroundColor: number[]
  legendTextColor: number[]
  multiplanarLayout: MULTIPLANAR_TYPE
  renderOverlayBlend: number
  sliceMosaicString: string
  centerMosaic: boolean
  // attach mouse click and touch screen event handlers for the canvas
  interactive: boolean
  penSize: number
  clickToSegment: boolean
  clickToSegmentRadius: number
  clickToSegmentBright: boolean
  clickToSegmentAutoIntensity: boolean // new option, but keep clickToSegmentBright for backwards compatibility
  clickToSegmentIntensityMax: number // also covers NaN
  clickToSegmentIntensityMin: number // also covers NaN
  clickToSegmentPercent: number
  clickToSegmentMaxDistanceMM: number // max distance in mm to consider for click to segment flood fill
  clickToSegmentIs2D: boolean
  // selection box outline thickness
  selectionBoxLineThickness: number
  selectionBoxIsOutline: boolean
  scrollRequiresFocus: boolean
  showMeasureUnits: boolean
  // measureTextJustify: "origin" | "terminus" | "center"
  measureTextJustify: 'start' | 'center' | 'end' // similar to flexbox justify start, end, center
  measureTextColor: number[]
  measureLineColor: number[]
  measureTextHeight: number
  isAlphaClipDark: boolean
  gradientOrder: number
  gradientOpacity: number
  invertScrollDirection: boolean
}

export const DEFAULT_OPTIONS: NVConfigOptions = {
  textHeight: 0.06,
  colorbarHeight: 0.05,
  crosshairWidth: 1,
  crosshairWidthUnit: 'voxels',
  crosshairGap: 0,
  rulerWidth: 4,
  show3Dcrosshair: false,
  backColor: [0, 0, 0, 1],
  crosshairColor: [1, 0, 0, 1],
  fontColor: [0.5, 0.5, 0.5, 1],
  selectionBoxColor: [1, 1, 1, 0.5],
  clipPlaneColor: [0.7, 0, 0.7, 0.5],
  clipThick: 2,
  clipVolumeLow: [0, 0, 0],
  clipVolumeHigh: [1.0, 1.0, 1.0],
  rulerColor: [1, 0, 0, 0.8],
  colorbarMargin: 0.05,
  trustCalMinMax: true,
  clipPlaneHotKey: 'KeyC',
  viewModeHotKey: 'KeyV',
  doubleTouchTimeout: 500,
  longTouchTimeout: 1000,
  keyDebounceTime: 50,
  isNearestInterpolation: false,
  isResizeCanvas: true,
  atlasOutline: 0,
  isRuler: false,
  isColorbar: false,
  isOrientCube: false,
  tileMargin: 0,
  multiplanarPadPixels: 0,
  // @deprecated
  multiplanarForceRender: false,
  multiplanarEqualSize: false,
  multiplanarShowRender: SHOW_RENDER.AUTO, // auto is the same behaviour as multiplanarForceRender: false
  isRadiologicalConvention: false,
  meshThicknessOn2D: Infinity,
  dragMode: DRAG_MODE_SECONDARY.contrast,
  dragModePrimary: DRAG_MODE_PRIMARY.crosshair,
  yoke3Dto2DZoom: false,
  isDepthPickMesh: false,
  isCornerOrientationText: false,
  heroImageFraction: 0,
  heroSliceType: SLICE_TYPE.RENDER,
  sagittalNoseLeft: false,
  isSliceMM: false,
  isV1SliceShader: false,
  forceDevicePixelRatio: 0,
  logLevel: 'info',
  loadingText: 'loading ...',
  isForceMouseClickToVoxelCenters: false,
  dragAndDropEnabled: true,
  drawingEnabled: false,
  penValue: 1,
  floodFillNeighbors: 6,
  isFilledPen: false,
  thumbnail: '',
  maxDrawUndoBitmaps: 8,
  sliceType: SLICE_TYPE.MULTIPLANAR,
  meshXRay: 0.0,
  isAntiAlias: null,
  limitFrames4D: NaN,
  isAdditiveBlend: false,
  showLegend: true,
  legendBackgroundColor: [0.3, 0.3, 0.3, 0.5],
  legendTextColor: [1.0, 1.0, 1.0, 1.0],
  multiplanarLayout: MULTIPLANAR_TYPE.AUTO,
  renderOverlayBlend: 1.0,
  sliceMosaicString: '',
  centerMosaic: false,
  penSize: 1, // in voxels, since all drawing is done using bitmap indices
  interactive: true,
  clickToSegment: false,
  clickToSegmentRadius: 3, // in mm
  clickToSegmentBright: true,
  clickToSegmentAutoIntensity: false, // new option, but keep clickToSegmentBright for backwards compatibility
  clickToSegmentIntensityMax: NaN, // NaN will use auto threshold (default flood fill behavior from before)
  clickToSegmentIntensityMin: NaN, // NaN will use auto threshold (default flood fill behavior from before)
  // 0 will use auto threshold (default flood fill behavior from before)
  // Take the voxel intensity at the click point and use this percentage +/- to threshold the flood fill operation.
  // If greater than 0, clickedVoxelIntensity +/- clickedVoxelIntensity * clickToSegmentPercent will be used
  // for the clickToSegmentIntensityMin and clickToSegmentIntensityMax values.
  clickToSegmentPercent: 0,
  clickToSegmentMaxDistanceMM: Number.POSITIVE_INFINITY, // default value is infinity for backwards compatibility with flood fill routine.
  clickToSegmentIs2D: false,
  selectionBoxLineThickness: 4,
  selectionBoxIsOutline: false,
  scrollRequiresFocus: false, // determines if the cavas need to be focused to scroll
  showMeasureUnits: true, // e.g. 20.2 vs 20.2 mm
  measureTextJustify: 'center', // start, center, end
  measureTextColor: [1, 0, 0, 1], // red
  measureLineColor: [1, 0, 0, 1], // red
  measureTextHeight: 0.03,
  isAlphaClipDark: false,
  gradientOrder: 1,
  gradientOpacity: 0.0,
  invertScrollDirection: false
}

type SceneData = {
  gamma: number
  azimuth: number
  elevation: number
  crosshairPos: vec3
  clipPlane: number[]
  clipPlaneDepthAziElev: number[]
  volScaleMultiplier: number
  pan2Dxyzmm: vec4
  clipThick: number
  clipVolumeLow: number[]
  clipVolumeHigh: number[]
}

export const INITIAL_SCENE_DATA = {
  gamma: 1.0,
  azimuth: 110,
  elevation: 10,
  crosshairPos: vec3.fromValues(0.5, 0.5, 0.5),
  clipPlane: [0, 0, 0, 0],
  clipPlaneDepthAziElev: [2, 0, 0],
  volScaleMultiplier: 1.0,
  pan2Dxyzmm: vec4.fromValues(0, 0, 0, 1),
  clipThick: 2.0,
  clipVolumeLow: [0, 0, 0],
  clipVolumeHigh: [1.0, 1.0, 1.0]
}

export type Scene = {
  onAzimuthElevationChange: (azimuth: number, elevation: number) => void
  onZoom3DChange: (scale: number) => void
  sceneData: SceneData
  renderAzimuth: number
  renderElevation: number
  volScaleMultiplier: number
  crosshairPos: vec3
  clipPlane: number[]
  clipPlaneDepthAziElev: number[]
  pan2Dxyzmm: vec4
  _elevation?: number
  _azimuth?: number
  gamma?: number
}

export type DocumentData = {
  title: string
  imageOptionsArray: ImageFromUrlOptions[]
  meshOptionsArray: unknown[]
  opts: NVConfigOptions
  previewImageDataURL: string
  labels: NVLabel3D[]
  encodedImageBlobs: string[]
  encodedDrawingBlob: string
  // TODO not sure if they should be here? They are needed for loadFromJSON
  meshesString?: string
  sceneData?: SceneData
  // TODO referenced in niivue/loadDocument
  connectomes?: string[]
  customData?: string
}

export type ExportDocumentData = {
  // base64 encoded images
  encodedImageBlobs: string[]
  // base64 encoded drawing
  encodedDrawingBlob: string
  // dataURL of the preview image
  previewImageDataURL: string
  // map of image ids to image options
  imageOptionsMap: Map<string, number>
  // array of image options to recreate images
  imageOptionsArray: ImageFromUrlOptions[]
  // data to recreate a scene
  sceneData: Partial<SceneData>
  // configuration options of {@link Niivue} instance
  opts: NVConfigOptions
  // encoded meshes
  meshesString: string
  // TODO the following fields were missing in the typedef
  labels: NVLabel3D[]
  connectomes: string[]
  customData: string
}

/**
 * Creates and instance of NVDocument
 * @ignore
 */
export class NVDocument {
  data: DocumentData = {
    title: 'Untitled document',
    imageOptionsArray: [],
    meshOptionsArray: [],
    opts: { ...DEFAULT_OPTIONS },
    previewImageDataURL: '',
    labels: [],
    encodedImageBlobs: [],
    encodedDrawingBlob: ''
  }

  scene: Scene

  volumes: NVImage[] = []
  meshDataObjects?: Array<NVMesh | NVConnectome>
  meshes: Array<NVMesh | NVConnectome> = []
  drawBitmap: Uint8Array | null = null
  imageOptionsMap = new Map()
  meshOptionsMap = new Map()

  constructor() {
    this.scene = {
      onAzimuthElevationChange: (): void => {},
      onZoom3DChange: (): void => {},
      sceneData: {
        ...INITIAL_SCENE_DATA,
        pan2Dxyzmm: vec4.fromValues(0, 0, 0, 1),
        crosshairPos: vec3.fromValues(0.5, 0.5, 0.5)
      },

      get renderAzimuth(): number {
        return this.sceneData.azimuth
      },
      set renderAzimuth(azimuth: number) {
        this.sceneData.azimuth = azimuth
        if (this.onAzimuthElevationChange) {
          this.onAzimuthElevationChange(this.sceneData.azimuth, this.sceneData.elevation)
        }
      },

      get renderElevation(): number {
        return this.sceneData.elevation
      },
      set renderElevation(elevation: number) {
        this.sceneData.elevation = elevation
        if (this.onAzimuthElevationChange) {
          this.onAzimuthElevationChange(this.sceneData.azimuth, this.sceneData.elevation)
        }
      },

      get volScaleMultiplier(): number {
        return this.sceneData.volScaleMultiplier
      },
      set volScaleMultiplier(scale: number) {
        this.sceneData.volScaleMultiplier = scale
        this.onZoom3DChange(scale)
      },

      get crosshairPos(): vec3 {
        return this.sceneData.crosshairPos
      },
      set crosshairPos(crosshairPos: vec3) {
        this.sceneData.crosshairPos = crosshairPos
      },

      get clipPlane(): number[] {
        return this.sceneData.clipPlane
      },
      set clipPlane(clipPlane) {
        this.sceneData.clipPlane = clipPlane
      },

      get clipPlaneDepthAziElev(): number[] {
        return this.sceneData.clipPlaneDepthAziElev
      },
      set clipPlaneDepthAziElev(clipPlaneDepthAziElev: number[]) {
        this.sceneData.clipPlaneDepthAziElev = clipPlaneDepthAziElev
      },

      get pan2Dxyzmm(): vec4 {
        return this.sceneData.pan2Dxyzmm
      },

      /**
       * Sets current 2D pan in 3D mm
       */
      set pan2Dxyzmm(pan2Dxyzmm) {
        this.sceneData.pan2Dxyzmm = pan2Dxyzmm
      },

      get gamma(): number {
        return this.sceneData.gamma
      },

      /**
       * Sets current gamma
       */
      set gamma(newGamma) {
        this.sceneData.gamma = newGamma
      }
    }
  }

  /**
   * Title of the document
   */
  get title(): string {
    return this.data.title
  }

  /**
   * Gets preview image blob
   * @returns dataURL of preview image
   */
  get previewImageDataURL(): string {
    return this.data.previewImageDataURL
  }

  /**
   * Sets preview image blob
   * @param dataURL - encoded preview image
   */
  set previewImageDataURL(dataURL: string) {
    this.data.previewImageDataURL = dataURL
  }

  /**
   * @param title - title of document
   */
  set title(title: string) {
    this.data.title = title
  }

  get imageOptionsArray(): ImageFromUrlOptions[] {
    return this.data.imageOptionsArray
  }

  /**
   * Gets the base 64 encoded blobs of associated images
   */
  get encodedImageBlobs(): string[] {
    return this.data.encodedImageBlobs
  }

  /**
   * Gets the base 64 encoded blob of the associated drawing
   * TODO the return type was marked as string[] here, was that an error?
   */
  get encodedDrawingBlob(): string {
    return this.data.encodedDrawingBlob
  }

  /**
   * Gets the options of the {@link Niivue} instance
   */
  get opts(): NVConfigOptions {
    return this.data.opts
  }

  /**
   * Sets the options of the {@link Niivue} instance
   */
  set opts(opts) {
    this.data.opts = { ...opts }
  }

  /**
   * Gets the 3D labels of the {@link Niivue} instance
   */
  get labels(): NVLabel3D[] {
    return this.data.labels
  }

  /**
   * Sets the 3D labels of the {@link Niivue} instance
   */
  set labels(labels: NVLabel3D[]) {
    this.data.labels = labels
  }

  get customData(): string | undefined {
    return this.data.customData
  }

  set customData(data: string) {
    this.data.customData = data
  }

  /**
   * Checks if document has an image by id
   */
  hasImage(image: NVImage): boolean {
    return this.volumes.find((i) => i.id === image.id) !== undefined
  }

  /**
   * Checks if document has an image by url
   */
  hasImageFromUrl(url: string): boolean {
    return this.data.imageOptionsArray.find((i) => i.url === url) !== undefined
  }

  /**
   * Adds an image and the options an image was created with
   */
  addImageOptions(image: NVImage, imageOptions: ImageFromUrlOptions): void {
    if (!this.hasImage(image)) {
      if (!imageOptions.name) {
        if (imageOptions.url) {
          const absoluteUrlRE = /^(?:[a-z+]+:)?\/\//i
          const url = absoluteUrlRE.test(imageOptions.url)
            ? new URL(imageOptions.url)
            : new URL(imageOptions.url, window.location.href)

          imageOptions.name = url.pathname.split('/').pop()! // TODO guaranteed?
          if (imageOptions.name.toLowerCase().endsWith('.gz')) {
            imageOptions.name = imageOptions.name.slice(0, -3)
          }

          if (!imageOptions.name.toLowerCase().endsWith('.nii')) {
            imageOptions.name += '.nii'
          }
        } else {
          imageOptions.name = 'untitled.nii'
        }
      }
    }

    imageOptions.imageType = NVIMAGE_TYPE.NII

    this.data.imageOptionsArray.push(imageOptions)
    this.imageOptionsMap.set(image.id, this.data.imageOptionsArray.length - 1)
  }

  /**
   * Removes image from the document as well as its options
   */
  removeImage(image: NVImage): void {
    if (this.imageOptionsMap.has(image.id)) {
      const index = this.imageOptionsMap.get(image.id)
      if (this.data.imageOptionsArray.length > index) {
        this.data.imageOptionsArray.splice(index, 1)
      }
      this.imageOptionsMap.delete(image.id)
    }
    this.volumes = this.volumes.filter((i) => i.id !== image.id)
  }

  /**
   * Returns the options for the image if it was added by url
   */
  getImageOptions(image: NVImage): ImageFromUrlOptions | null {
    return this.imageOptionsMap.has(image.id) ? this.data.imageOptionsArray[this.imageOptionsMap.get(image.id)] : null
  }

  /**
   * Converts NVDocument to JSON
   */
  json(): ExportDocumentData {
    const data: Partial<ExportDocumentData> = {
      encodedImageBlobs: [],
      previewImageDataURL: this.data.previewImageDataURL,
      imageOptionsMap: new Map()
    }
    const imageOptionsArray = []
    // save our scene object
    data.sceneData = { ...this.scene.sceneData }
    // save our options
    data.opts = { ...this.opts }
    // infinity is a symbol
    if (this.opts.meshThicknessOn2D === Infinity) {
      data.opts.meshThicknessOn2D = 'infinity'
    }

    data.labels = [...this.data.labels]

    // remove any handlers
    for (const label of data.labels) {
      delete label.onClick
    }

    data.customData = this.customData

    // volumes
    // TODO move this to a per-volume export function in NVImage?
    if (this.volumes.length) {
      for (let i = 0; i < this.volumes.length; i++) {
        const volume = this.volumes[i]
        let imageOptions = this.getImageOptions(volume)

        if (imageOptions === null) {
          log.warn('no options found for image, using default')
          imageOptions = {
            name: '',
            colormap: 'gray',
            opacity: 1.0,
            pairedImgData: null,
            cal_min: NaN,
            cal_max: NaN,
            trustCalMinMax: true,
            percentileFrac: 0.02,
            ignoreZeroVoxels: false,
            useQFormNotSForm: false,
            colormapNegative: '',
            colormapLabel: null,
            imageType: NVIMAGE_TYPE.NII,
            frame4D: 0,
            limitFrames4D: NaN,
            // TODO the following were missing
            url: '',
            urlImageData: '',
            alphaThreshold: false,
            cal_minNeg: NaN,
            cal_maxNeg: NaN,
            colorbarVisible: true
          }
        } else {
          if (!('imageType' in imageOptions)) {
            imageOptions.imageType = NVIMAGE_TYPE.NII
          }
        }
        // update image options on current image settings
        imageOptions.colormap = volume.colormap
        imageOptions.colormapLabel = volume.colormapLabel
        imageOptions.opacity = volume.opacity
        imageOptions.cal_max = volume.cal_max || NaN
        imageOptions.cal_min = volume.cal_min || NaN

        imageOptionsArray.push(imageOptions)

        const encodedImageBlob = NVUtilities.uint8tob64(volume.toUint8Array())
        data.encodedImageBlobs!.push(encodedImageBlob)
        data.imageOptionsMap!.set(volume.id, i)
      }
    }
    // Add it even if it's empty
    data.imageOptionsArray = [...imageOptionsArray]

    // meshes
    const meshes = []
    data.connectomes = []
    for (const mesh of this.meshes) {
      if (mesh.type === MeshType.CONNECTOME) {
        data.connectomes.push(JSON.stringify((mesh as NVConnectome).json()))
        continue
      }
      const copyMesh: Mutable<any> = {
        pts: mesh.pts,
        tris: mesh.tris,
        name: mesh.name,
        rgba255: Uint8Array.from(mesh.rgba255),
        opacity: mesh.opacity,
        connectome: mesh.connectome,
        dpg: mesh.dpg,
        dps: mesh.dps,
        dpv: mesh.dpv,
        meshShaderIndex: mesh.meshShaderIndex,
        layers: mesh.layers.map((layer) => ({
          values: layer.values,
          nFrame4D: layer.nFrame4D,
          frame4D: 0,
          outlineBorder: layer.outlineBorder,
          global_min: layer.global_min,
          global_max: layer.global_max,
          cal_min: layer.cal_min,
          cal_max: layer.cal_max,
          opacity: layer.opacity,
          colormap: layer.colormap,
          colormapNegative: layer.colormapNegative,
          colormapLabel: layer.colormapLabel,
          useNegativeCmap: layer.useNegativeCmap
        })),
        hasConnectome: mesh.hasConnectome,
        edgeColormap: mesh.edgeColormap,
        edgeColormapNegative: mesh.edgeColormapNegative,
        edgeMax: mesh.edgeMax,
        edgeMin: mesh.edgeMin,
        edges: mesh.edges && Array.isArray(mesh.edges) ? [...mesh.edges] : [],
        extentsMax: mesh.extentsMax,
        extentsMin: mesh.extentsMin,
        furthestVertexFromOrigin: mesh.furthestVertexFromOrigin,
        nodeColormap: mesh.nodeColormap,
        nodeColormapNegative: mesh.nodeColormapNegative,
        nodeMaxColor: mesh.nodeMaxColor,
        nodeMinColor: mesh.nodeMinColor,
        nodeScale: mesh.nodeScale,
        legendLineThickness: mesh.legendLineThickness,
        offsetPt0: mesh.offsetPt0,
        nodes: mesh.nodes
      }
      if (mesh.offsetPt0 && mesh.offsetPt0.length > 0) {
        copyMesh.offsetPt0 = mesh.offsetPt0
        copyMesh.fiberGroupColormap = mesh.fiberGroupColormap
        copyMesh.fiberColor = mesh.fiberColor
        copyMesh.fiberDither = mesh.fiberDither
        copyMesh.fiberRadius = mesh.fiberRadius
        copyMesh.colormap = mesh.colormap
      }
      meshes.push(copyMesh)
    }
    data.meshesString = JSON.stringify(serialize(meshes))
    // Serialize drawBitmap
    if (this.drawBitmap) {
      data.encodedDrawingBlob = NVUtilities.uint8tob64(this.drawBitmap)
    }

    return data as ExportDocumentData
  }

  /**
   * Downloads a JSON file with options, scene, images, meshes and drawing of {@link Niivue} instance
   */
  async download(fileName: string, compress: boolean): Promise<void> {
    const data = this.json()
    const dataText = JSON.stringify(data)
    const contentType = compress ? 'application/gzip' : 'application/json'
    let content: string | ArrayBuffer
    if (compress) {
      content = await NVUtilities.compressStringToArrayBuffer(dataText)
    } else {
      content = dataText
    }

    NVUtilities.download(content, fileName, contentType)
  }

  /**
   * Deserialize mesh data objects
   */
  static deserializeMeshDataObjects(document: NVDocument): void {
    if (document.data.meshesString) {
      document.meshDataObjects = deserialize(JSON.parse(document.data.meshesString))
      for (const mesh of document.meshDataObjects!) {
        for (const layer of mesh.layers) {
          if ('colorMap' in layer) {
            layer.colormap = layer.colorMap as string
            delete layer.colorMap
          }
          if ('colorMapNegative' in layer) {
            layer.colormapNegative = layer.colorMapNegative as string
            delete layer.colorMapNegative
          }
        }
      }
    }
  }

  /**
   * Factory method to return an instance of NVDocument from a URL
   */
  static async loadFromUrl(url: string): Promise<NVDocument> {
    const response = await fetch(url)
    const buffer = await response.arrayBuffer()
    let documentData: DocumentData

    if (NVUtilities.isArrayBufferCompressed(buffer)) {
      // The file is gzip compressed
      const documentText = await NVUtilities.decompressArrayBuffer(buffer)
      documentData = JSON.parse(documentText)
    } else {
      const utf8decoder = new TextDecoder()
      documentData = JSON.parse(utf8decoder.decode(buffer))
    }

    return NVDocument.loadFromJSON(documentData)
  }

  /**
   * Factory method to return an instance of NVDocument from a File object
   */
  static async loadFromFile(file: Blob): Promise<NVDocument> {
    const arrayBuffer = await NVUtilities.readFileAsync(file)
    let dataString: string
    const document = new NVDocument()

    if (NVUtilities.isArrayBufferCompressed(arrayBuffer)) {
      dataString = await NVUtilities.decompressArrayBuffer(arrayBuffer)
    } else {
      const utf8decoder = new TextDecoder()
      dataString = utf8decoder.decode(arrayBuffer)
    }
    document.data = JSON.parse(dataString)

    if (document.data.opts.meshThicknessOn2D === 'infinity') {
      document.data.opts.meshThicknessOn2D = Infinity
    }
    document.scene.sceneData = { ...INITIAL_SCENE_DATA, ...document.data.sceneData }

    NVDocument.deserializeMeshDataObjects(document)
    return document
  }

  /**
   * Factory method to return an instance of NVDocument from JSON
   */
  static loadFromJSON(data: DocumentData): NVDocument {
    const document = new NVDocument()
    document.data = data
    if (document.data.opts.meshThicknessOn2D === 'infinity') {
      document.data.opts.meshThicknessOn2D = Infinity
    }
    document.scene.sceneData = { ...INITIAL_SCENE_DATA, ...data.sceneData }
    NVDocument.deserializeMeshDataObjects(document)
    return document
  }
}
