// nvdocument.ts (updated)
// --- imports
import { vec3, vec4 } from 'gl-matrix'
import { NVSerializer } from '@/nvserializer' // adjust path if needed

import { NVUtilities } from '@/nvutilities'
import { ImageFromUrlOptions, NVIMAGE_TYPE, NVImage } from '@/nvimage'
import { NVMesh } from '@/nvmesh'
import { NVLabel3D } from '@/nvlabel'
import { NVConnectome } from '@/nvconnectome'

/**
 * Represents a completed measurement between two points
 */
export interface CompletedMeasurement {
    startMM: vec3 // World coordinates in mm for start point
    endMM: vec3 // World coordinates in mm for end point
    distance: number // Distance between points in mm
    sliceIndex: number
    sliceType: SLICE_TYPE
    slicePosition: number
}

/**
 * Represents a completed angle measurement between two lines
 */
export interface CompletedAngle {
    firstLineMM: { start: vec3; end: vec3 } // World coordinates in mm for first line
    secondLineMM: { start: vec3; end: vec3 } // World coordinates in mm for second line
    angle: number // Angle in degrees
    sliceIndex: number
    sliceType: SLICE_TYPE
    slicePosition: number
}

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

export enum PEN_TYPE {
    PEN = 0,
    RECTANGLE = 1,
    ELLIPSE = 2
}

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,
    angle = 7,
    crosshair = 8,
    windowing = 9
}

export interface MouseEventConfig {
    leftButton: {
        primary: DRAG_MODE
        withShift?: DRAG_MODE
        withCtrl?: DRAG_MODE
    }
    rightButton: DRAG_MODE
    centerButton: DRAG_MODE
}

export interface TouchEventConfig {
    singleTouch: DRAG_MODE
    doubleTouch: DRAG_MODE
}

/**
 * NVConfigOptions
 */
export type NVConfigOptions = {
    // ... (kept unchanged for brevity — same as your original file)
    textHeight: number
    fontSizeScaling: number
    fontMinPx: number
    colorbarHeight: number
    colorbarWidth: number
    showColorbarBorder: boolean
    crosshairWidth: number
    crosshairWidthUnit: 'voxels' | 'mm' | 'percent'
    crosshairGap: number
    rulerWidth: number
    show3Dcrosshair: boolean
    backColor: number[]
    crosshairColor: number[]
    fontColor: Float32List
    selectionBoxColor: number[]
    clipPlaneColor: number[]
    isClipPlanesCutaway: boolean
    isClipAllVolumes: boolean
    paqdUniforms: number[]
    rulerColor: number[]
    colorbarMargin: number
    trustCalMinMax: boolean
    clipPlaneHotKey: string
    cycleClipPlaneHotKey: string
    viewModeHotKey: string
    doubleTouchTimeout: number
    longTouchTimeout: number
    keyDebounceTime: number
    isNearestInterpolation: boolean
    atlasOutline: number
    atlasActiveIndex: number
    isRuler: boolean
    isColorbar: boolean
    isOrientCube: boolean
    tileMargin: number
    multiplanarPadPixels: number
    multiplanarForceRender: boolean
    multiplanarEqualSize: boolean
    multiplanarShowRender: SHOW_RENDER
    isRadiologicalConvention: boolean
    meshThicknessOn2D: number | string
    dragMode: DRAG_MODE
    dragModePrimary: DRAG_MODE
    mouseEventConfig?: MouseEventConfig
    touchEventConfig?: TouchEventConfig
    yoke3Dto2DZoom: boolean
    isDepthPickMesh: boolean
    isCornerOrientationText: boolean
    isOrientationTextVisible: boolean
    showAllOrientationMarkers: boolean
    heroImageFraction: number
    heroSliceType: SLICE_TYPE
    sagittalNoseLeft: boolean
    isSliceMM: boolean
    isV1SliceShader: boolean
    forceDevicePixelRatio: number
    logLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent'
    loadingText: string
    isForceMouseClickToVoxelCenters: boolean
    dragAndDropEnabled: boolean
    drawingEnabled: boolean
    penValue: number
    penType: PEN_TYPE
    floodFillNeighbors: number
    isFilledPen: boolean
    thumbnail: string
    maxDrawUndoBitmaps: number
    sliceType: SLICE_TYPE
    isAntiAlias: boolean | null
    isAdditiveBlend: boolean
    isResizeCanvas: boolean
    meshXRay: number
    limitFrames4D: number
    showLegend: boolean
    legendBackgroundColor: number[]
    legendTextColor: number[]
    multiplanarLayout: MULTIPLANAR_TYPE
    renderOverlayBlend: number
    sliceMosaicString: string
    centerMosaic: boolean
    interactive: boolean
    penSize: number
    clickToSegment: boolean
    clickToSegmentRadius: number
    clickToSegmentBright: boolean
    clickToSegmentAutoIntensity: boolean
    clickToSegmentIntensityMax: number
    clickToSegmentIntensityMin: number
    clickToSegmentPercent: number
    clickToSegmentMaxDistanceMM: number
    clickToSegmentIs2D: boolean
    selectionBoxLineThickness: number
    selectionBoxIsOutline: boolean
    scrollRequiresFocus: boolean
    showMeasureUnits: boolean
    measureTextJustify: 'start' | 'center' | 'end'
    measureTextColor: number[]
    measureLineColor: number[]
    measureTextHeight: number
    isAlphaClipDark: boolean
    gradientOrder: number
    gradientOpacity: number
    renderSilhouette: number
    gradientAmount: number
    invertScrollDirection: boolean
    is2DSliceShader: boolean
    bounds: [[number, number], [number, number]] | null
    showBoundsBorder?: boolean
    boundsBorderColor?: number[] // [r,g,b,a]
    windowingGainFactor: number
    // Zarr options
    /** Chunk cache size for zarr viewing (default 500) */
    zarrCacheSize: number
    /** Number of chunk rings to prefetch around the visible region for zarr viewing (0 disables, default 1) */
    zarrPrefetchRings: number
    /** Smooth drawing surfaces in 3D rendering. 0 = off, > 0 = Box blur radius in voxels (default 0) */
    smoothDrawing: number
}

export const DEFAULT_OPTIONS: NVConfigOptions = {
    // ... (same defaults as your original file)
    textHeight: -1.0,
    fontSizeScaling: 0.4,
    fontMinPx: 13,
    colorbarHeight: 0.05,
    colorbarWidth: -1,
    showColorbarBorder: true,
    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],
    isClipPlanesCutaway: false,
    isClipAllVolumes: false,
    paqdUniforms: [0.3, 0.5, 0.5, 1.0],
    rulerColor: [1, 0, 0, 0.8],
    colorbarMargin: 0.05,
    trustCalMinMax: true,
    clipPlaneHotKey: 'KeyC',
    cycleClipPlaneHotKey: 'KeyP',
    viewModeHotKey: 'KeyV',
    doubleTouchTimeout: 500,
    longTouchTimeout: 1000,
    keyDebounceTime: 50,
    isNearestInterpolation: false,
    isResizeCanvas: true,
    atlasOutline: 0,
    atlasActiveIndex: 0,
    isRuler: false,
    isColorbar: false,
    isOrientCube: false,
    tileMargin: 0,
    multiplanarPadPixels: 0,
    multiplanarForceRender: false,
    multiplanarEqualSize: false,
    multiplanarShowRender: SHOW_RENDER.AUTO,
    isRadiologicalConvention: false,
    meshThicknessOn2D: Infinity,
    dragMode: DRAG_MODE.contrast,
    dragModePrimary: DRAG_MODE.crosshair,
    mouseEventConfig: undefined,
    touchEventConfig: undefined,
    yoke3Dto2DZoom: false,
    isDepthPickMesh: false,
    isCornerOrientationText: false,
    isOrientationTextVisible: true,
    showAllOrientationMarkers: 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,
    penType: PEN_TYPE.PEN,
    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,
    interactive: true,
    clickToSegment: false,
    clickToSegmentRadius: 3,
    clickToSegmentBright: true,
    clickToSegmentAutoIntensity: false,
    clickToSegmentIntensityMax: NaN,
    clickToSegmentIntensityMin: NaN,
    clickToSegmentPercent: 0,
    clickToSegmentMaxDistanceMM: Number.POSITIVE_INFINITY,
    clickToSegmentIs2D: false,
    selectionBoxLineThickness: 4,
    selectionBoxIsOutline: false,
    scrollRequiresFocus: false,
    showMeasureUnits: true,
    measureTextJustify: 'center',
    measureTextColor: [1, 0, 0, 1],
    measureLineColor: [1, 0, 0, 1],
    measureTextHeight: 0.06,
    isAlphaClipDark: false,
    gradientOrder: 1,
    gradientOpacity: 0.0,
    renderSilhouette: 0.0,
    gradientAmount: 0.0,
    invertScrollDirection: false,
    is2DSliceShader: false,
    bounds: null,
    showBoundsBorder: false,
    boundsBorderColor: [1, 1, 1, 1], // white border by default
    windowingGainFactor: 2,
    // Zarr options
    zarrCacheSize: 1000,
    zarrPrefetchRings: 10,
    smoothDrawing: 0
}

//
// -- NEW: Recursive encoded type for NVConfigOptions JSON-safe form
//
type EncodeNumbersIn<T> = T extends number ? number | string : T extends Array<infer U> ? Array<EncodeNumbersIn<U>> : T extends object ? { [K in keyof T]: EncodeNumbersIn<T[K]> } : T

type EncodedNVConfigOptions = EncodeNumbersIn<NVConfigOptions>

//
// Utility encode/decode helpers
//

export const DEFAULT_SCENE_DATA = {} // placeholder if needed elsewhere (kept for completeness)

type SceneData = {
    gamma: number
    azimuth: number
    elevation: number
    crosshairPos: vec3
    clipPlanes: number[][]
    clipPlaneDepthAziElevs: number[][]
    volScaleMultiplier: number
    pan2Dxyzmm: vec4
}

export const INITIAL_SCENE_DATA = {
    gamma: 1.0,
    azimuth: 110,
    elevation: 10,
    crosshairPos: vec3.fromValues(0.5, 0.5, 0.5),
    clipPlanes: [[0, 0, 0, 0]],
    clipPlaneDepthAziElevs: [[2, 0, 0]],
    volScaleMultiplier: 1.0,
    pan2Dxyzmm: vec4.fromValues(0, 0, 0, 1)
}

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[]
    clipPlanes: number[][]
    clipPlaneDepthAziElevs: number[][]
    pan2Dxyzmm: vec4
    _elevation?: number
    _azimuth?: number
    gamma?: number
}

/**
 * DocumentData / ExportDocumentData types (kept minimal here)
 */
export type DocumentData = {
    title?: string
    imageOptionsArray?: ImageFromUrlOptions[]
    meshOptionsArray?: unknown[]
    opts?: Partial<EncodedNVConfigOptions> | Partial<NVConfigOptions>
    previewImageDataURL?: string
    labels?: NVLabel3D[]
    encodedImageBlobs?: string[]
    encodedDrawingBlob?: string
    meshesString?: string
    sceneData?: Partial<SceneData>
    connectomes?: string[]
    customData?: string
    completedMeasurements?: CompletedMeasurement[]
    completedAngles?: CompletedAngle[]
}

export type ExportDocumentData = {
    title?: string
    encodedImageBlobs: string[]
    encodedDrawingBlob: string
    previewImageDataURL: string
    imageOptionsMap: Map<string, number>
    imageOptionsArray: ImageFromUrlOptions[]
    sceneData: Partial<SceneData>
    opts: EncodedNVConfigOptions | Partial<EncodedNVConfigOptions>
    meshesString: string
    meshOptionsArray?: unknown[]
    labels: NVLabel3D[]
    connectomes: string[]
    customData: string
    completedMeasurements: CompletedMeasurement[]
    completedAngles: CompletedAngle[]
}

/**
 * Returns a partial configuration object containing only the fields in the provided
 * options that differ from the DEFAULT_OPTIONS.
 */
// function diffOptions(opts: NVConfigOptions, defaults: NVConfigOptions): Partial<NVConfigOptions> {
//     const diff: Partial<NVConfigOptions> = {}
//     for (const key in opts) {
//         const value = opts[key]
//         const def = defaults[key]
//         const isArray = Array.isArray(value) && Array.isArray(def)

//         if ((isArray && value.some((v, i) => v !== def[i])) || (!isArray && value !== def)) {
//             diff[key] = value
//         }
//     }
//     return diff
// }

/**
 * NVDocument class (main)
 */
export class NVDocument {
    data: DocumentData = {
        title: 'Untitled document',
        imageOptionsArray: [],
        meshOptionsArray: [],
        opts: { ...DEFAULT_OPTIONS } as any,
        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()
    completedMeasurements: CompletedMeasurement[] = []
    completedAngles: CompletedAngle[] = []

    private _optsProxy: NVConfigOptions | null = null
    private _optsChangeCallback: ((propertyName: keyof NVConfigOptions, newValue: NVConfigOptions[keyof NVConfigOptions], oldValue: NVConfigOptions[keyof NVConfigOptions]) => void) | null = null

    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.clipPlanes[0] ?? []
            },
            set clipPlane(clipPlane) {
                this.sceneData.clipPlanes[0] = clipPlane
            },

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

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

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

            set pan2Dxyzmm(pan2Dxyzmm) {
                this.sceneData.pan2Dxyzmm = pan2Dxyzmm
            },

            get gamma(): number {
                return this.sceneData.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
     */
    get encodedDrawingBlob(): string {
        return this.data.encodedDrawingBlob
    }

    /**
     * Gets the options of the {@link Niivue} instance
     */
    get opts(): NVConfigOptions {
        if (!this._optsProxy) {
            this._createOptsProxy()
        }
        return this._optsProxy as NVConfigOptions
    }

    /**
     * Sets the options of the {@link Niivue} instance
     */
    set opts(opts) {
        this.data.opts = { ...opts } as any
        this._optsProxy = null // Force recreation of proxy
    }

    /**
     * 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)
    }

    /**
     * Fetch any image data that is missing from this document.
     */
    async fetchLinkedData(): Promise<void> {
        this.data.encodedImageBlobs = []
        if (!this.imageOptionsArray?.length) {
            return
        }

        for (const imgOpt of this.imageOptionsArray) {
            if (!imgOpt.url) {
                continue
            }

            try {
                const response = await fetch(imgOpt.url)
                if (!response.ok) {
                    console.warn('Failed to fetch image:', imgOpt.url)
                    continue
                }

                const buffer = await response.arrayBuffer()
                const uint8Array = new Uint8Array(buffer)
                const b64 = NVUtilities.uint8tob64(uint8Array)
                this.data.encodedImageBlobs.push(b64)

                console.info('fetch linked data fetched from ', imgOpt.url)
            } catch (err) {
                console.warn(`Failed to fetch/encode image from ${imgOpt.url}:`, err)
            }
        }
    }

    /**
     * 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
    }

    /**
     * Serialise the document by delegating to NVSerializer.
     */
    json(embedImages = true, embedDrawing = true): ExportDocumentData {
        // NVSerializer is responsible for converting typed arrays, encoding special numbers,
        // producing meshesString, and returning an ExportDocumentData object.
        return NVSerializer.serializeDocument(this, embedImages, embedDrawing)
    }

    async download(fileName: string, compress: boolean, opts: { embedImages: boolean } = { embedImages: true }): Promise<void> {
        const data = this.json(opts.embedImages)
        const jsonTxt = JSON.stringify(data)
        const mime = compress ? 'application/gzip' : 'application/json'
        const payload = compress ? await NVUtilities.compressStringToArrayBuffer(jsonTxt) : jsonTxt

        NVUtilities.download(payload, fileName, mime)
    }

    /**
     * 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)) {
            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)
    }

    static async loadFromFile(file: Blob): Promise<NVDocument> {
        const arrayBuffer = await NVUtilities.readFileAsync(file)
        let dataString: string

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

        const documentData = JSON.parse(dataString) as DocumentData
        return NVDocument.loadFromJSON(documentData)
    }

    /**
     * Factory method to return an instance of NVDocument from JSON.
     * Delegates the main parsing to NVSerializer, then applies NVDocument-specific
     * post-processing (opts decode, scene defaults, clone measurements/angles).
     */
    static async loadFromJSON(data: DocumentData): Promise<NVDocument> {
        return await NVSerializer.deserializeDocument(data)
    }

    /**
     * Sets the callback function to be called when opts properties change
     */
    setOptsChangeCallback(callback: (propertyName: keyof NVConfigOptions, newValue: NVConfigOptions[keyof NVConfigOptions], oldValue: NVConfigOptions[keyof NVConfigOptions]) => void): void {
        this._optsChangeCallback = callback
        this._optsProxy = null // Force recreation with new callback
    }

    /**
     * Removes the opts change callback
     */
    removeOptsChangeCallback(): void {
        this._optsChangeCallback = null
        this._optsProxy = null // Force recreation without callback
    }

    /**
     * Creates a Proxy wrapper around the opts object to detect changes
     */
    private _createOptsProxy(): void {
        const target = this.data.opts as NVConfigOptions

        this._optsProxy = new Proxy(target, {
            set: (obj: any, prop: string | symbol, value: any): boolean => {
                const oldValue = obj[prop]

                // Only proceed if the value actually changed
                if (oldValue !== value) {
                    obj[prop] = value

                    // Call the change callback if one is registered
                    if (this._optsChangeCallback && typeof prop === 'string' && prop in DEFAULT_OPTIONS) {
                        this._optsChangeCallback(prop as keyof NVConfigOptions, value, oldValue)
                    }
                }

                return true
            },

            get: (obj: any, prop: string | symbol): any => {
                return obj[prop]
            }
        })
    }
}
