import {
    arrayBufferToBase64,
    base64ToArrayBuffer,
    deepAccessObject,
    getTypedArray,
    safeSetProperty,
    Serialization,
    Serializer,
} from 'ts-browser-helpers'
import {
    AnimationClip,
    ArcCurve,
    CanvasTexture,
    CatmullRomCurve3,
    Color,
    CubeTexture,
    CubicBezierCurve,
    CubicBezierCurve3,
    Curve,
    CurvePath,
    DataTexture,
    EllipseCurve,
    Euler,
    LineCurve,
    LineCurve3,
    Material,
    MaterialLoader,
    Matrix2,
    Matrix3,
    Matrix4,
    ObjectLoader,
    Path,
    QuadraticBezierCurve,
    QuadraticBezierCurve3,
    Quaternion,
    Shape,
    Source,
    Spherical,
    SplineCurve,
    Texture,
    Vector2,
    Vector3,
    Vector4,
} from 'three'
import type {AssetImporter, AssetManager, BlobExt, IAssetImporter, ImportResultExtras} from '../assetmanager'
import type {ThreeViewer} from '../viewer'
import type {IMaterial, IObject3D, ITexture} from '../core'
import type {IRenderTarget, RenderManager} from '../rendering'
import {isNonRelativeUrl} from './browser-helpers'
import {textureToCanvas} from '../three'

const copier = (c: any) => (v: any, o: any) => o?.copy?.(v) ?? new c().copy(v)

export class ThreeSerialization {

    static Primitives = [
        [Vector2, 'isVector2', ['x', 'y'], 1],
        [Vector3, 'isVector3', ['x', 'y', 'z'], 1],
        [Vector4, 'isVector4', ['x', 'y', 'z', 'w'], 1],
        [Quaternion, 'isQuaternion', ['x', 'y', 'z', 'w'], 1],
        [Euler, 'isEuler', ['x', 'y', 'z', 'order'], 1],
        [Color, 'isColor', ['r', 'g', 'b'], 1],
        [Matrix2, 'isMatrix2', ['elements'], 1],
        [Matrix3, 'isMatrix3', ['elements'], 1],
        [Matrix4, 'isMatrix4', ['elements'], 1],
        [Spherical, 'isSpherical', ['radius', 'phi', 'theta'], 1],
        // todo Plane etc (has Vector2)
    ] as const

    static PrimitiveSerializer(cls: any, isType: string, props: string[]|Readonly<string[]>, priority = 1): Serializer {
        return {
            priority: priority,
            isType: (obj: any) => obj?.[isType] /* || obj?.metadata?.type === cls.name*/,
            serialize: (obj: any) => {
                // if (!obj?.[isType]) throw new Error(`Expected a ${cls.name}`)
                const ret = {[isType]: true}
                for (const k of props) ret[k] = obj[k]
                return ret
            },
            deserialize: copier(cls),
            // @ts-expect-error type in next version
            type: isType.startsWith('is') ? isType.slice(2) : cls.name,
        }
    }

    static Texture: Serializer = {
        priority: 2,
        isType: (obj: any) => obj.isTexture || obj.metadata?.type === 'Texture',
        serialize: (obj: any, meta?: SerializationMetaType) => {
            if (!obj?.isTexture) throw new Error('Expected a texture')
            if (obj.isRenderTargetTexture) return undefined // todo: support render targets
            // if (obj.isRenderTargetTexture && !obj.userData?.serializableRenderTarget) return undefined
            if (meta?.textures[obj.uuid]) return {uuid: obj.uuid, resource: 'textures'}
            const imgData = obj.source.data
            const hasRootPath = !obj.isRenderTargetTexture && obj.userData.rootPath && typeof obj.userData.rootPath === 'string' &&
                isNonRelativeUrl(obj.userData.rootPath)
            let res = {} as any
            const ud = obj.userData
            try { // need try catch here because of hasRootPath
                if (hasRootPath) {
                    if (obj.source.data) {
                        if (!obj.userData.embedUrlImagePreviews) // todo make sure its only Texture, check for svg etc
                            obj.source.data = null // handled in GLTFWriter2.processImage
                        else {
                            obj.source.data = textureToCanvas(obj, 16, obj.flipY) // todo: check flipY
                        }
                    }
                }
                obj.userData = {} // toJSON will call JSON.stringify, which will serialize userData
                const meta2 = {images: {} as any} // in-case meta is undefined
                res = obj.toJSON(meta || meta2)
                if (!meta && res.image) res.image = hasRootPath && !obj.userData.embedUrlImagePreviews ? undefined : meta2.images[res.image]
                res.userData = Serialization.Serialize(copyTextureUserData({}, ud), meta, false)
            } catch (e) {
                console.error('ThreeSerialization: Unable to serialize texture')
                console.error(e)
            }
            obj.userData = ud // should be outside try catch
            if (hasRootPath) {
                if (meta && !obj.userData.embedUrlImagePreviews) delete meta.images[obj.source.uuid] // because its empty. uuid still stored in the texture.image
                obj.source.data = imgData
            }

            if (meta?.textures && res && !res.resource) {
                if (!meta.textures[res.uuid])
                    meta.textures[res.uuid] = res
                res = {uuid: res.uuid, resource: 'textures'}
            }
            return res
        },
        deserialize: (dat: any, obj: any, meta?: SerializationMetaType) => {
            if (dat.isTexture) return dat
            if (dat.resource === 'textures' && meta?.textures?.[dat.uuid]) return meta.textures[dat.uuid]

            console.warn('Cannot deserialize texture into object like primitive, since textures need to be loaded asynchronously. Trying with ObjectLoader. Load events might not work properly.', dat, obj)
            const loader = meta?._context.objectLoader ?? new ObjectLoader(meta?._context.assetImporter?.loadingManager)
            const data = {...dat}
            if (typeof data.image === 'string') {
                if (!meta?.images) {
                    console.error('ThreeSerialization: Cannot deserialize texture with image url without meta.images', data)
                } else {
                    data.image = meta.images[data.image]
                }
            }
            if (!data.image || typeof data.image === 'string' || !data.image.isSource && !data.image.url) {
                console.error('ThreeSerialization: Cannot deserialize texture', data)
                return obj
            }
            let imageOnLoad: undefined | (()=>void)
            if (meta && !data.image.isSource) {
                if (!meta._context.imagePromises) meta._context.imagePromises = []
                meta._context.imagePromises.push(new Promise<void>((resolve) => {
                    imageOnLoad = resolve
                }))
            }
            const sources = data.image.isSource ? {[data.image.uuid]: data.image as Source} : loader.parseImages([data.image], imageOnLoad)
            data.image = Object.keys(sources)[0]
            if (meta?.images) meta.images[data.image] = sources[data.image]
            if (data.userData) data.userData = ThreeSerialization.Deserialize(data.userData, {}, meta)
            const textures = loader.parseTextures([data], sources)
            const uuid = Object.keys(textures)[0]
            if (!uuid || !textures[uuid]) {
                console.error('ThreeSerialization: Cannot deserialize texture', data)
                return obj
            }
            if (meta?.textures) meta.textures[uuid] = textures[uuid]
            return textures[uuid]
        },
    }

    static SerializableMaterials = new Set<IMaterial['constructor']>()

    static Material: Serializer = {
        priority: 2,
        isType: (obj: any) => obj.isMaterial || obj.metadata?.type === 'Material',
        serialize: (obj: any, meta?: SerializationMetaType) => {
            if (!obj?.isMaterial) throw new Error('Expected a material')
            if (meta?.materials?.[obj.uuid]) return {uuid: obj.uuid, resource: 'materials'}

            // serialize textures separately
            const meta2 = meta ?? {textures: {}, images: {}}
            const objTextures: any = {}
            const tempTextures: any = {}

            const propList = Object.keys(obj.constructor.MaterialProperties || obj) // todo use MapProperties? or iMaterialCommons.getMapsForMaterial
            for (const k of propList) {
                if (k.startsWith('__')) continue // skip private/internal textures/properties
                const v = obj[k]
                if (v?.isTexture) {
                    const ser = Serialization.Serialize(v, meta2)
                    objTextures[k] = ser
                    tempTextures[k] = v
                    obj[k] = ser ? {isTexture: true, toJSON: ()=> ser} : null // because of how threejs Material.toJSON serializes textures
                }
            }

            // Serialize without userData because three.js tries to convert it to string. We are serializing it separately
            const userData = obj.userData
            obj.userData = {}
            let res = {} as any
            try {
                res = obj.toJSON(meta || meta2, true) // copying userData is handled in toJSON, see MeshStandardMaterial2
                serializeMaterialUserData(res, userData, meta)
                res.userData.uuid = userData.uuid
                // todo: override generator to mention that this is a custom serializer?
                if (obj.constructor.TYPE) res.type = obj.constructor.TYPE // override type if specified as static property in the class
                // Remove undefined values. Note that null values are kept.
                for (const key of Object.keys(res)) if (res[key] === undefined) delete res[key]
            } catch (e) {
                console.error('ThreeSerialization: Unable to serialize material')
                console.error(e)
            }
            obj.userData = userData
            // Restore textures
            for (const [k, v] of Object.entries(tempTextures)) {
                obj[k] = v
                delete tempTextures[k]
            }

            // Add material, textures, images to meta
            // serialize textures are already added to meta by the texture serializer
            if (res) {
                if (meta) {
                    for (const [k, v] of Object.entries(objTextures)) {
                        if (v) res[k] = v // can be undefined because of RenderTargetTexture...
                    }
                    if (meta.materials) {
                        if (!meta.materials[res.uuid])
                            meta.materials[res.uuid] = res
                        res = {uuid: res.uuid, resource: 'materials'}
                    }
                } else {
                    for (const [k, v] of Object.entries(objTextures)) {
                        if (v) res[k] = (v as any).uuid // to remain compatible with how three.js saves
                    }
                    res.textures = Object.values(meta2.textures)
                    res.images = Object.values(meta2.images)
                }
            }
            return res
        },
        deserialize: (dat: any, obj: any, meta?: SerializationMetaType) => {
            function finalCopy(material: Material) {
                if (material.isMaterial) {
                    if (obj?.isMaterial && obj.uuid === material.uuid) {
                        if (obj !== material && typeof obj.setValues === 'function') {
                            console.warn('ThreeSerialization: Material uuid already exists, copying values to old material')
                            obj.setValues(material)
                        }
                        return obj
                    } else {
                        return material
                    }
                }
                return undefined
            }

            let ret = finalCopy(dat)
            if (ret !== undefined) return ret
            if (dat.resource === 'materials' && meta?.materials?.[dat.uuid]) {
                ret = finalCopy(meta.materials[dat.uuid])
                if (ret !== undefined) return ret
                console.error('ThreeSerialization: cannot find material in meta', dat, ret)
            }

            const type = dat.type
            if (!type) {
                console.error('ThreeSerialization: Cannot deserialize material without type', dat)
                return obj
            }

            const data = {...dat} as Record<string, any>
            if (data.userData) data.userData = Serialization.Deserialize(data.userData, undefined, meta, false)
            //
            const textures: Record<string, Texture> = {}
            for (const [k, v] of Object.entries(data)) { // for textures
                if (typeof v === 'string' && meta?.textures?.[v]) {
                    data[k] = meta.textures[v]
                    textures[k] = meta.textures[v]
                }
                if (!v || !v.resource || typeof v.resource !== 'string') continue
                const resource = meta?.[v.resource as 'textures'|'extras']?.[v.uuid]
                data[k] = resource || null
                if (v.resource === 'textures' && resource?.isTexture) {
                    textures[k] = resource
                }
            }

            // we have 2 options, either obj is null or it is a material.
            // if the material is not the same type, we can't use it, should we throw an error or create a new material and assign it. maybe a warning and create a new material?
            // to create a material, we need to know the type, type->material initialization can be done in either material manager or MaterialLoader

            // data has deserialized textures and userData, assuming the rest can be deserialized by material.fromJSON

            if (!obj || !obj.isMaterial || obj.type !== type && obj.constructor?.TYPE !== type) {
                if (obj && Object.keys(obj).length) console.warn('ThreeSerialization: Material type mismatch during deserialize, creating a new material', obj, data, type, obj.constructor?.type)
                obj = null
            }

            // if obj is not null
            if (obj && (!data.uuid || obj.uuid === data.uuid)) {
                if (obj.fromJSON) obj.fromJSON(data, meta, true)
                else if (obj.setValues) obj.setValues(data)
                else console.error('ThreeSerialization: Cannot deserialize material, no fromJSON or setValues method', obj, data)
                return obj
            }

            // obj is null or type mismatch, so ignore obj and create a new material

            // find a material class with the type registered in SerializableMaterials
            const uuid = dat.isMaterial ? undefined : dat.uuid
            let template = null as IMaterial['constructor'] | null
            for (const m of ThreeSerialization.SerializableMaterials) {
                if (m.TYPE === type) {
                    template = m
                    break
                }
            }
            if (!template) {
                for (const m of ThreeSerialization.SerializableMaterials) {
                    if (m.TypeAlias?.includes(type)) {
                        template = m
                        break
                    }
                }
            }
            if (template) {
                const material = new template()
                if (material) {
                    if (uuid) {
                        safeSetProperty(material, 'uuid', uuid, true, true)
                    }
                    if (material.fromJSON) material.fromJSON(data, meta, true)
                    else if (material.setValues) material.setValues(data)
                    else console.error('ThreeSerialization: Cannot deserialize material, no fromJSON or setValues method', material, data)
                    return material
                }
            }

            // todo use loader from context to load instead of this
            console.warn('Legacy three.js material deserialization')

            // normal three.js material
            const loader = new MaterialLoader()
            for (const [k, v] of Object.entries(textures)) {
                data[k] = v.uuid
            }
            const texs = {...loader.textures}
            loader.setTextures(textures)
            const mat = loader.parse(data)
            if (data.uuid) {
                safeSetProperty(mat, 'uuid', data.uuid, true, true)
            }
            loader.setTextures(texs)

            ret = finalCopy(mat)
            if (ret !== undefined) return ret
            console.error('ThreeSerialization: cannot deserialize material', dat, ret, mat)

        },
    }

    static RenderTarget: Serializer = {
        priority: 2,
        isType: (obj: any) => obj.isWebGLRenderTarget || obj.metadata?.type === 'RenderTarget',
        serialize: (obj: IRenderTarget, meta?: SerializationMetaType) => {
            if (!obj?.isWebGLRenderTarget || !obj.uuid) throw new Error('Expected a IRenderTarget')
            if (meta?.extras[obj.uuid]) return {uuid: obj.uuid, resource: 'extras'}

            // This is for the class implementing IRenderTarget, check {@link RenderTargetManager} for class implementation
            const tex = Array.isArray(obj.texture) ? obj.texture[0] : obj.texture
            let res: any = {
                metadata: {type: 'RenderTarget'},
                uuid: obj.uuid,
                width: obj.width,
                height: obj.height,
                depth: obj.depth,
                sizeMultiplier: obj.sizeMultiplier,
                count: Array.isArray(obj.texture) ? obj.texture.length : undefined,
                isCubeRenderTarget: obj.isWebGLCubeRenderTarget || undefined,
                isTemporary: obj.isTemporary,
                textureName: Array.isArray(obj.texture) ? obj.texture.map(t => t.name) : obj.texture?.name,
                options: {
                    wrapS: tex?.wrapS,
                    wrapT: tex?.wrapT,
                    magFilter: tex?.magFilter,
                    minFilter: tex?.minFilter,
                    format: tex?.format,
                    type: tex?.type,
                    anisotropy: tex?.anisotropy,
                    depthBuffer: !!obj.depthBuffer,
                    stencilBuffer: !!obj.stencilBuffer,
                    generateMipmaps: tex?.generateMipmaps,
                    depthTexture: !!obj.depthTexture,
                    colorSpace: tex?.colorSpace,
                    samples: obj.samples,
                },
            }

            if (meta?.extras) {
                if (!meta.extras[res.uuid])
                    meta.extras[res.uuid] = res
                res = {uuid: res.uuid, resource: 'extras'}
            }
            return res
        },
        deserialize: (dat: any, obj: any, meta?: SerializationMetaType) => {
            if (obj?.uuid === dat.uuid) return obj
            if (dat.isWebGLRenderTarget) return dat

            const renderManager = meta?._context.renderManager
            if (!renderManager) {
                console.error('ThreeSerialization: Cannot deserialize render target without render manager', dat)
                return obj
            }
            if (dat.isWebGLCubeRenderTarget || dat.isTemporary) {
                // todo support cube, temporary render target here
                console.warn('ThreeSerialization: Cannot deserialize WebGLCubeRenderTarget or temporary render target yet', dat)
                return obj
            }

            const res = renderManager.createTarget({
                sizeMultiplier: dat.sizeMultiplier || undefined,
                size: dat.sizeMultiplier ? undefined : {width: dat.width, height: dat.height},
                textureCount: dat.count,
                ...dat.options,
            })
            if (dat.textureName) {
                if (Array.isArray(dat.textureName) && Array.isArray(res.texture)) {
                    for (let i = 0; i < dat.textureName.length; i++) {
                        res.texture[i].name = dat.textureName[i]
                    }
                } else if (!Array.isArray(res.texture)) {
                    res.texture.name = Array.isArray(dat.textureName) ? dat.textureName[0] : dat.textureName
                }
            }
            if (!res) return res
            res.uuid = dat.uuid
            if (meta?.extras) meta.extras[dat.uuid] = res
            return res
        },
    }

    private static _init = false

    static Init() {
        if (this._init) return
        this._init = true
        // @ts-expect-error not sure why it's not set in three.js
        Spherical.prototype.isSpherical = true
        Serialization.RegisterSerializer(...ThreeSerialization.Primitives.map(p=>ThreeSerialization.PrimitiveSerializer(p[0], p[1], p[2], p[3])))
        Serialization.RegisterSerializer(ThreeSerialization.Texture)
        Serialization.RegisterSerializer(ThreeSerialization.Material)
        Serialization.RegisterSerializer(ThreeSerialization.RenderTarget)

        // these classes have toJSON/fromJSON and .type
        Serialization.SerializableClasses.set('Shape', Shape) // todo this could be large, it should be a resource in meta for duplicates
        Serialization.SerializableClasses.set('Curve', Curve)
        Serialization.SerializableClasses.set('CurvePath', CurvePath)
        Serialization.SerializableClasses.set('Path', Path)
        Serialization.SerializableClasses.set('ArcCurve', ArcCurve)
        Serialization.SerializableClasses.set('CatmullRomCurve3', CatmullRomCurve3)
        Serialization.SerializableClasses.set('CubicBezierCurve', CubicBezierCurve)
        Serialization.SerializableClasses.set('CubicBezierCurve3', CubicBezierCurve3)
        Serialization.SerializableClasses.set('EllipseCurve', EllipseCurve)
        Serialization.SerializableClasses.set('LineCurve', LineCurve)
        Serialization.SerializableClasses.set('LineCurve3', LineCurve3)
        Serialization.SerializableClasses.set('QuadraticBezierCurve', QuadraticBezierCurve)
        Serialization.SerializableClasses.set('QuadraticBezierCurve3', QuadraticBezierCurve3)
        Serialization.SerializableClasses.set('SplineCurve', SplineCurve)
        Serialization.SerializableClasses.set('AnimationClip', AnimationClip)
        // Serialization.SerializableClasses.set('Skeleton', Skeleton) // doesnt have .type. todo add to three.js
    }

    static MakeSerializable(constructor: ObjectConstructor, type: string, props?: (string|[string, string])[]) {
        (constructor.prototype as any).serializableClassId = type
        Serialization.SerializableClasses.set(type, constructor)
        if (props) Serialization.TypeMap.set(constructor, props.map(p=>typeof p === 'string' ? [p, p] : p))
    }

    /**
     * Serialize an object
     * {@link Serialization.Serialize}
     */
    static Serialize(obj: any, meta?: Partial<SerializationMetaType>, isThis = false) {
        if (!this._init) this.Init()
        return Serialization.Serialize(obj, meta, isThis)
    }

    /**
     * Deserialize an object
     * {@link Serialization.Deserialize}
     */
    static Deserialize(data: any, obj: any, meta?: Partial<SerializationMetaType>, isThis = false) {
        if (!this._init) this.Init()
        return Serialization.Deserialize(data, obj, meta, isThis)
    }

}

/**
 * Deep copy/clone from source to dest, assuming both are userData objects for three.js objects/materials/textures etc.
 * This will clone any property that can be cloned (apart from Object3D, Texture, Material) and deep copy the objects and arrays.
 * @note Keep synced with copyMaterialUserData in three.js -> Material.js todo: merge these functions? by putting this inside three.js?
 * @param dest
 * @param source
 * @param ignoredKeysInRoot - keys to ignore in the root object
 */
export function copyUserData(dest: any, source: any, ignoredKeysInRoot: (string|symbol)[] = []): any {
    if (!source) return dest
    for (const key of Object.keys(source)) {
        if (ignoredKeysInRoot.includes(key)) continue
        if (key.startsWith('__')) continue // double underscore
        const src = source[key]
        if (typeof dest[key] === 'function' || typeof src === 'function') continue
        const skipClone = !src || src.isTexture || src.isObject3D || src.isMaterial || src.isBufferGeometry || src.userDataSkipClone
        if (!skipClone && typeof src.clone === 'function')
            dest[key] = src.clone()
        // else if (!skipClone && (typeof src === 'object' || Array.isArray(src)))
        else if (!skipClone && (src.constructor === Object || Array.isArray(src)))
            dest[key] = copyUserData(Array.isArray(src) ? [] : {}, src, [])
        else
            dest[key] = src
    }
    return dest
}

/**
 * Deep copy/clone from source to dest, assuming both are userData objects in Textures.
 * Same as {@link copyUserData} but ignores uuid in the root object.
 * @param dest
 * @param source
 * @param ignoredKeysInRoot
 */
export function copyTextureUserData(dest: any, source: any, ignoredKeysInRoot = ['uuid']): any {
    return copyUserData(dest, source, ignoredKeysInRoot)
}


/**
 * Deep copy/clone from source to dest, assuming both are userData objects in Materials.
 * Same as {@link copyUserData} but ignores uuid in the root object.
 * @note Keep synced with copyMaterialUserData in three.js -> Material.js
 * @param dest
 * @param source
 * @param ignoredKeysInRoot
 */
export function copyMaterialUserData(dest: any, source: any, ignoredKeysInRoot = ['uuid']): any {
    return copyUserData(dest, source, ignoredKeysInRoot)
}


/**
 * Deep copy/clone from source to dest, assuming both are userData objects in Object3D.
 * Same as {@link copyUserData} but ignores uuid in the root object.
 * @param dest
 * @param source
 * @param ignoredKeysInRoot
 */
export function copyObject3DUserData(dest: any, source: any, ignoredKeysInRoot = ['uuid']): any {
    return copyUserData(dest, source, ignoredKeysInRoot)
}

/**
 * Serialize userData and sets to data.userData. This is required because three.js Material.toJSON does not serialize userData.
 * @param data
 * @param userData
 * @param meta
 */
function serializeMaterialUserData(data: any, userData: any, meta?: SerializationMetaType) {
    data.userData = {}

    copyMaterialUserData(data.userData, userData)

    // Serialize the userData
    const meta2 = meta || { // Make meta object for the Serializer from the data. This requires changing from Array to Object for textures and images
        textures: Object.fromEntries(data.textures?.map((t: any) => [t.uuid, t]) || []),
        images: Object.fromEntries(data.images?.map((t: any) => [t.uuid, t]) || []),
    }
    data.userData = Serialization.Serialize(data.userData, meta2) // here meta is required for textures otherwise images will be lost. Material.toJSON sets the result as meta if not provided.
    if (!meta) {
        // Add textures and images to the result if meta is not provided. This is to remain compatible with how three.js saves materials. See (MaterialLoader and JSONMaterialLoader)
        if (Object.keys(meta2.textures).length > 0) data.textures = Object.values(meta2.textures)
        if (Object.keys(meta2.images).length > 0) data.images = Object.values(meta2.images)
    }
}

/**
 * Converts array buffers to base64 strings in meta.
 * This is useful when storing .json files, as storing as number arrays takes a lot of space.
 * Used in viewer.toJSON()
 * @param meta
 */
export function convertArrayBufferToStringsInMeta(meta: SerializationMetaType) {
    Object.values(meta).forEach((res: any) => { // similar to processViewer in gltf export.
        if (res) Object.values(res).forEach((item: any) => {
            if (!item.url) return
            // console.log(item.url)
            if (!(item.url.data instanceof ArrayBuffer) && !Array.isArray(item.url.data)) return
            if (item.url.type === 'Uint16Array') {
                if (!(item.url.data instanceof Uint16Array)) { // because it can be a typed array
                    item.url.data = new Uint16Array(item.url.data)
                }
                item.url.data = 'data:application/octet-stream;base64,' + arrayBufferToBase64(item.url.data.buffer)
            } else if (item.url.type === 'Uint8Array') {
                if (!(item.url.data instanceof Uint8Array)) { // because it can be a typed array
                    item.url.data = new Uint8Array(item.url.data)
                }
                // todo: just use jpeg or PNG encoding for this ?
                item.url.data = 'data:application/octet-stream;base64,' + arrayBufferToBase64(item.url.data.buffer)
            } else if (item.url.data instanceof ArrayBuffer) {
                item.url.data = 'data:application/octet-stream;base64,' + arrayBufferToBase64(item.url.data.buffer)
            } else {
                console.warn('Unsupported buffer type', item.url.type)
            }
        })
    })
}

/**
 * Converts strings(base64 or utf-8) to array buffers in meta. This is the reverse of {@link convertArrayBufferToStringsInMeta}
 * Used in viewer.fromJSON()
 */
export function convertStringsToArrayBuffersInMeta(meta: SerializationMetaType) {
    Object.values(meta).forEach((res: any) => { // similar to processViewer in gltf export.
        if (res) Object.values(res).forEach((item: any) => {
            if (!item || !item.url) return
            if (typeof item.url.data !== 'string') return

            // base64 data uri or any mime type
            // console.log(item.url.data?.match?.(/^data:.*;base64,(.*)$/))
            const dataUriMatch = item.url.data.match(/^data:.*;base64,(.*)$/)
            if (dataUriMatch?.[1]) {
                item.url.data = base64ToArrayBuffer(dataUriMatch?.[1])
            } else { // utf-8 string, not used at the moment
                if (item.url.type !== 'Uint8Array') {
                    console.error('ThreeSerialization: Unsupported buffer type string for ', item.url.type, 'use base64')
                }
                item.url.data = new TextEncoder().encode(item.url.data).buffer // todo: this doesnt work in ie/edge maybe, but this feature is not used.
            }

        })
    })
}

export function getEmptyMeta(res?: Partial<SerializationResourcesType>): SerializationMetaType {
    return { // see Object3D.js toJSON for more details
        geometries: {...res?.geometries},
        materials: {...res?.materials},
        textures: {...res?.textures},
        images: {...res?.images},
        shapes: {...res?.shapes},
        skeletons: {...res?.skeletons},
        animations: {...res?.animations},
        extras: {...res?.extras},
        typed: {...res?.typed},
        _context: {},
    }
}

export interface SerializationResourcesType {
    geometries: Record<string, any>,
    materials: Record<string, any>,
    textures: Record<string, any>,
    images: Record<string, any>,
    shapes: Record<string, any>,
    skeletons: Record<string, any>,
    animations: Record<string, any>,
    extras: Record<string, any>,
    typed: Record<string, any>,
    object?: any, // todo what is this used for?

    [key: string]: any,

}
export interface SerializationMetaType extends SerializationResourcesType {
    _context: {
        assetImporter?: AssetImporter,
        objectLoader?: ObjectLoader,
        assetManager?: AssetManager,
        renderManager?: RenderManager,

        imagePromises?: Promise<any>[],
        viewer?: ThreeViewer,

        [key: string]: any,
    }

    __isLoadedResources?: boolean

}
export class MetaImporter {

    /**
     * @param json
     * @param extraResources - preloaded resources in the format of viewer config resources.
     */
    static async ImportMeta(json: SerializationMetaType, extraResources?: Partial<SerializationResourcesType>) {
        // console.log(json)
        if (json.__isLoadedResources) return json

        const resources: SerializationMetaType = metaFromResources()
        resources.__isLoadedResources = true
        resources._context = json._context

        convertStringsToArrayBuffersInMeta(json)

        // console.log(viewerConfig)
        const assetImporter = json._context.assetImporter
        if (!assetImporter) throw new Error('assetImporter not found in meta context, which is required for import meta.')

        const objLoader = json._context.objectLoader || new ObjectLoader(assetImporter.loadingManager)

        // see ObjectLoader.parseAsync
        resources.animations = json.animations ? objLoader.parseAnimations(Object.values(json.animations)) : {}
        if (extraResources && extraResources.animations) resources.animations = {...resources.animations, ...extraResources.animations}

        resources.shapes = json.shapes ? objLoader.parseShapes(Object.values(json.shapes)) : {}
        if (extraResources && extraResources.shapes) resources.shapes = {...resources.shapes, ...extraResources.shapes}

        resources.geometries = json.geometries ? objLoader.parseGeometries(Object.values(json.geometries), resources.shapes) : {}
        if (extraResources && extraResources.geometries) resources.geometries = {...resources.geometries, ...extraResources.geometries}

        resources.images = json.images ? await objLoader.parseImagesAsync(Object.values(json.images)) : {} // local images only like data url and data textures
        if (extraResources && extraResources.images) resources.images = {...resources.images, ...extraResources.images}

        // const onLoad = () => { // todo: do it after all the images not after one
        //     Object.values(resources.textures).forEach((t: any) => {
        //         if (t.isTexture && t.image?.complete) t.needsUpdate = true
        //     })
        // }

        if (Array.isArray(json.textures)) {
            console.error('ThreeSerialization: TODO: check file format')
            json.textures = json.textures.reduce((acc, cur) => {
                if (!cur) return acc
                acc[cur.uuid] = cur
                return acc
            })
        }

        await MetaImporter.LoadRootPathTextures({textures: json.textures, images: resources.images}, assetImporter)

        // console.log(json.textures)
        const textures = []
        for (const texture of Object.values(json.textures)) {
            const tex = {...texture}
            if (tex.userData) tex.userData = ThreeSerialization.Deserialize(tex.userData, {}, resources)
            textures.push(tex)
        }
        resources.textures = json.textures ? objLoader.parseTextures(textures, resources.images) : {}

        for (const key1 of Object.keys(resources.textures)) {
            let tex: Texture|undefined = resources.textures[key1]
            if (!tex) continue
            // __texCtor is set in MetaImporter.LoadRootPathTextures
            if (tex.source.__texCtor) {
                const newTex: Texture = new tex.source.__texCtor(tex.source.data)
                if (!newTex || typeof newTex.copy !== 'function') continue
                newTex.copy(tex)
                delete tex.source.__texCtor
                resources.textures[key1] = newTex
                tex = newTex
            }
            if (tex.source.data instanceof HTMLCanvasElement && !(tex as CanvasTexture).isCanvasTexture) {
                const newTex = new CanvasTexture(tex.source.data).copy(tex)
                resources.textures[key1] = newTex
                tex = newTex
            }
        }

        // replace the source of the textures(which has preview) with the loaded images, see {@link LoadRootPathTextures} for `rootPathPromise`
        // todo: should this be moved after processRaw?
        const textures2 = {...resources.textures}
        for (const inpTexture of Object.values(json.textures)) {
            inpTexture.rootPathPromise?.then((v: Source|null) => {
                if (!v) return
                const texture = textures2[inpTexture.uuid]
                texture.dispose()
                texture.source = v
                texture.source.needsUpdate = true
                texture.needsUpdate = true
            })
        }

        for (const entry of Object.entries(resources.textures)) {
            entry[1] = await assetImporter.processRawSingle(entry[1], {})
            if (entry[1]) resources.textures[entry[0]] = entry[1]
            else delete resources.textures[entry[0]]
        }
        if (extraResources && extraResources.textures) resources.textures = {...resources.textures, ...extraResources.textures}


        const jsonMats: any[] = json.materials ? Object.values(json.materials) : []
        resources.materials = {}
        for (const material of jsonMats) {
            if (!material?.uuid) continue
            // Object.entries(material).forEach(([k, data]: [string, any]) => {
            //     if (data && data.resource && data.uuid && data.resource === 'textures') { // for textures put in by serialize.ts
            //         material[k] = data.uuid
            //     }
            // })
            resources.materials[material.uuid] = ThreeSerialization.Deserialize(material, undefined, resources)
        }
        if (extraResources && extraResources.materials) resources.materials = {...resources.materials, ...extraResources.materials}

        if (json.object) {
            resources.object = objLoader.parseObject(json.object, resources.geometries, resources.materials, resources.textures, resources.animations)
            if (json.skeletons) {
                resources.skeletons = objLoader.parseSkeletons(Object.values(json.skeletons), resources.object as any)
                objLoader.bindSkeletons(resources.object as any, resources.skeletons)
            }
        }

        if (json.extras) {
            resources.extras = json.extras
            for (const e of (Object.values(json.extras) as any as any[])) { // todo parallel import
                if (!e.uuid) continue
                if (!e.url) {
                    resources.extras[e.uuid] = ThreeSerialization.Deserialize(e, undefined, resources)
                    continue
                }
                // see LUTCubeTextureWrapper, KTX2LoadPlugin for sample use
                if (typeof e.url === 'string') {
                    const r = await assetImporter.importSingle(e.url, e.userData?.rootPathOptions || {}) // todo rootPathOptions is not being set when exporting extras right now
                    if (r) resources.extras[e.uuid] = r
                } else if (e.url.data) {
                    const file = new File([getTypedArray(e.url.type, e.url.data)], e.url.path)
                    const r = await assetImporter.importSingle({path: file.name, file}, e.userData?.rootPathOptions || {}, undefined, false) // false is passed to mark it as external
                    // todo: userdata? name? other properties?
                    if (r) resources.extras[e.uuid] = r
                } else {
                    console.warn('invalid URL type while loading extra resource')
                }
            }
            // console.log(resources.extras)
        }
        if (extraResources && extraResources.extras) resources.extras = {...resources.extras, ...extraResources.extras}

        resources.typed = {}
        if (json.typed) {
            for (const [key, item] of Object.entries(json.typed)) {
                if (typeof item.rootPath === 'string' && item.external) { // todo parallel import
                    const r = await assetImporter.importSingle(item.rootPath, item.rootPathOptions || {})
                    if (r) resources.typed[key] = r
                } else {
                    resources.typed[key] = ThreeSerialization.Deserialize(item, undefined, resources)
                }
            }
        }
        if (extraResources && extraResources.typed) resources.typed = {...resources.typed, ...extraResources.typed}

        // console.log(resources, json)
        return resources
    }


    // todo see _loadObjectDependencies2
    static async LoadRootPathTextures({textures, images}: Pick<SerializationMetaType, 'textures'|'images'>, importer: IAssetImporter, usePreviewImages = true) {
        const pms = []

        for (const inpTexture of Array.isArray(textures) ? textures : Object.values(textures ?? {} as any) as any as any[]) {
            const path = inpTexture?.userData?.rootPath
            const hasImage = usePreviewImages && inpTexture.image && images[inpTexture.image] // its possible to have both image and rootPath, then the image will be preview image.
            if (!path) continue
            const promise = importer.importSingle<ITexture>(path, inpTexture.userData.rootPathOptions || {}).then((texture) => {
                const source = texture?.source as any
                if (!texture || !texture.isTexture || !source) {
                    console.error('AssetImporter: Imported rootPath is not a Texture', path, texture)
                    return
                }
                // console.log(typeof image)
                const source2 = new Source(source.data)
                if (inpTexture.image) source2.uuid = inpTexture.image
                inpTexture.image = source2.uuid

                // only these are supported by ObjectLoader.parseTextures, see parseTextures2
                if (texture.constructor !== Texture && texture.constructor !== DataTexture && texture.constructor !== CubeTexture) {
                    source2.__texCtor = texture.constructor as typeof Texture
                }

                if (!hasImage) images[source2.uuid] = source2

                texture.dispose()
                return source2
            }).catch((e) => {
                console.error('ThreeSerialization: Error loading texture from rootPath', inpTexture.userData.rootPath)
                console.error(e)
                delete inpTexture.userData.rootPath
                return null
            })
            if (hasImage) inpTexture.rootPathPromise = promise
            else pms.push(promise)
        }

        await Promise.allSettled(pms)
    }

}

export function metaToResources(meta?: SerializationMetaType): Partial<SerializationResourcesType> {
    if (!meta) return {}
    const res: Partial<SerializationResourcesType> = {...meta}
    if (res._context) delete res._context
    return res
}

export function mergeResources(target: Partial<SerializationResourcesType>, source: Partial<SerializationResourcesType>) {
    for (const key of Object.keys(source)) {
        if (key === 'object') continue
        if (!target[key]) target[key] = {}
        Object.assign(target[key]!, source[key]!)
    }
    return target
}

export function metaFromResources(resources?: Partial<SerializationResourcesType>, viewer?: ThreeViewer): SerializationMetaType {
    return {
        ...resources,
        ...getEmptyMeta(resources),
        _context: {
            assetManager: viewer?.assetManager,
            assetImporter: viewer?.assetManager.importer,
            renderManager: viewer?.renderManager,
            viewer: viewer,
        }, // clear context even if its present in resources
    }
}

export function jsonToBlob(json: any): BlobExt {
    const b = new Blob([JSON.stringify(json)], {type: 'application/json'}) as BlobExt
    b.ext = 'json'
    return b
}

/**
 * Used in {@link LUTCubeTextureWrapper} and {@link KTX2LoadPlugin} and imported in {@link ThreeViewer.loadConfigResources}
 * @param texture
 * @param meta
 * @param name
 * @param mime
 */
export function serializeTextureInExtras(texture: ITexture & ImportResultExtras, meta: any, name?: string, mime?: string) {
    if (meta?.extras[texture.uuid]) return {uuid: texture.uuid, resource: 'extras'}

    let url: any = ''
    if (texture.source?._sourceImgBuffer || texture.__sourceBuffer) {
        // serialize blob to data in image.
        // Note: do not change to Uint16Array because it's encoded to rgbe in `processViewer`
        const data = new Uint8Array(texture.source?._sourceImgBuffer || texture.__sourceBuffer as ArrayBuffer)
        const mimeType = mime || texture.userData.mimeType || ''
        url = {
            data: Array.from(data), // texture need to be a normal array, not a typed array.
            type: data.constructor.name,
            path: texture.userData.__sourceBlob?.name || texture.userData.rootPath || 'file.' + mimeType.split('/')[1],
        }
        if (mimeType) url.mimeType = mimeType
    } else if (texture.userData.rootPath) {
        url = texture.userData.rootPath
    } else {
        console.error('ThreeSerialization: Unable to serialize LUT texture, not loaded through asset manager.')
    }

    const tex = {
        uuid: texture.uuid,
        url,
        userData: copyTextureUserData({}, texture.userData),
        type: texture.type,
        name: name || texture.name,
    }
    if (meta?.extras) {
        meta.extras[texture.uuid] = tex
        return {uuid: texture.uuid, resource: 'extras'}
    }
    return tex
}

declare module 'three'{
    export interface Source{
        ['__texCtor']?: typeof Texture
    }
}


export function getPartialProps(obj: IObject3D|IMaterial, props1?: string[]) {
    // copy properties from res1 to obj except those in sProperties
    const props: Record<string, any> = {}
    const sProps = Array.isArray(props1) ? props1 : []
    for (const sProp of sProps) {
        const deep = sProp.startsWith('userData.')
        let res2
        if (deep) {
            res2 = deepAccessObject(sProp.slice('userData.'.length), obj.userData, false)
        } else {
            res2 = (obj as any)[sProp]
        }
        if (res2 !== undefined) {
            props[sProp] = res2
        }
    }
    return props
}

export function setPartialProps(props: Record<string, any>, obj: IMaterial|IObject3D) {
    for (const sProp of Object.keys(props)) {
        const value = props[sProp]
        const deep = sProp.startsWith('userData.')
        if (!deep) {
            (obj as any)[sProp] = value
        } else {
            const tar = obj.userData
            const parts = sProp.split('.')
            const tarkey = parts.slice(1, -1)
            const tar2 = parts.length && tarkey.length ? deepAccessObject(tarkey, tar, false) : undefined
            if (tar2 !== undefined) {
                const key = parts[parts.length - 1]
                tar2[key] = value
            } else {
                // todo for userData deep property it will fail since parent object wouldnt exist in empty object. we need to create the empty target recursively if it doesnt exists
                console.warn('ThreeSerialization: setSProps: invalid sProperty', sProp, 'in', obj)
            }
        }
    }
}

