import {AViewerPluginSync, ThreeViewer} from '../../viewer'
import {generateUiConfig, uiButton, uiDropdown, uiInput, UiObjectConfig, uiToggle} from 'uiconfig.js'
import {MaterialExtension} from '../../materials'
import {Box3, DirectionalLight, Group, MathUtils, Matrix4, Object3D, ShaderChunk, Vector2, Vector3} from 'three'
import {onChange, serialize} from 'ts-browser-helpers'
import {DirectionalLight2, ICamera, IObject3D, IObject3DEventMap, ISceneEventMap} from '../../core'
import {shaderReplaceString} from '../../utils'

/**
 * Configuration data for CSM (Cascaded Shadow Maps) light parameters
 */
export interface CSMLightData {
    /** Number of shadow cascades. Default: 3 */
    cascades?: number;
    /** Shadow map resolution for each cascade. Default: 2048 */
    shadowMapSize?: number;
    /** Shadow bias to prevent shadow acne. If undefined, uses light's existing bias */
    shadowBias?: number|undefined;
    /** Near plane distance for shadow camera. If undefined, uses light's existing near */
    lightNear?: number|undefined;
    /** Far plane distance for shadow camera. If undefined, uses light's existing far */
    lightFar?: number|undefined;
    // lightRadius?: number;
    /** Margin around the frustum bounds for shadow calculation. Default: 200 */
    lightMargin?: number;
}

const defaultData = {
    cascades: 3,
    // maxFar: 100000,
    // mode: 'practical',
    shadowMapSize: 2048,
    shadowBias: undefined,
    lightNear: undefined,
    lightFar: undefined,
    lightMargin: 200,
    // lightRadius: 1,
} as const satisfies CSMLightData

/**
 * Cascaded Shadow Maps (CSM) plugin for high-quality directional light shadows across large scenes.
 *
 * This plugin implements cascaded shadow mapping to provide better shadow quality across
 * different distances from the camera by splitting the view frustum into multiple cascades,
 * each with its own shadow map at an appropriate resolution.
 *
 * Features:
 * - Multiple cascade splitting modes: uniform, logarithmic, practical, and custom
 * - Automatic light attachment to first directional light found
 * - Configurable shadow parameters per light
 * - Material extension for proper shadow sampling
 * - Optional fade between cascades
 *
 * Original three-csm implementation - https://github.com/StrandedKitty/three-csm
 * @example
 * ```typescript
 * const viewer = new ThreeViewer({
 *     plugins: [new CascadedShadowsPlugin()]
 * })
 *
 * const light = new DirectionalLight2(0xffffff, 1.5)
 * viewer.scene.addObject(light)
 *
 * const csmPlugin = viewer.getPlugin(CascadedShadowsPlugin)!
 * csmPlugin.setLightParams({
 *     cascades: 4,
 *     shadowMapSize: 1024,
 *     lightMargin: 100
 * }, light)
 * ```
 */
export class CascadedShadowsPlugin extends AViewerPluginSync {
    public static readonly PluginType = 'CascadedShadowsPlugin'

    /** Enable/disable the cascaded shadow maps plugin */
    @uiToggle()
    @serialize()
    @onChange('setDirty')
        enabled = true

    /** Current camera used for frustum calculations */
    camera?: ICamera // todo camera onchange
    /** Parent object containing all CSM lights */
    parent: Object3D = new Group()

    /**
     * Total cascades
     */
    // @onChange('refreshLights')
    // totalCascades

    // @onChange('refreshLights')
    //     cascades = 3
    //
    /** Maximum far distance for shadow calculation */
    @onChange('cameraNeedsUpdate')
    @serialize()
    @uiInput()
        maxFar = 100000

    /** Cascade splitting mode: uniform, logarithmic, practical, or custom */
    @onChange('cameraNeedsUpdate')
    @serialize()
    @uiDropdown(undefined, ['uniform', 'logarithmic', 'practical'/* , 'custom'*/])
        mode: 'uniform'|'logarithmic'|'practical'|'custom' = 'practical'

    /**
     * Automatically attach to first found directional light in the scene that casts shadow, if none is attached yet.
     * Call {@link refreshAttachedLight} to manually trigger light search.
     */
    @uiToggle()
    @serialize()
        attachToFirstLight = true

    /** Enable fade between cascades for smoother transitions */
    @onChange('cameraNeedsUpdate')
    @serialize()
    @uiToggle()
        fade: boolean

    // todo
    //  multi light support can also be added
    //  directional light only for now
    //  patch ui config for attached lights
    //  add light helper option?

    /** The main directional light that CSM will be applied to */
    @onChange('refreshLights')
        light?: DirectionalLight&IObject3D

    /** Custom callback for defining cascade splits when mode is 'custom' */
    @onChange('cameraNeedsUpdate')
        customSplitsCallback?: (amount: number, near: number, far: number, breaks: number[]) => void

    /** Main camera frustum for cascade calculation */
    mainFrustum: CSMFrustum
    /** Individual frustums for each cascade */
    frustums: CSMFrustum[] = []
    /** Cascade break points in normalized depth [0-1] */
    breaks: number[] = []
    /** Extended break data for shader uniforms */
    extendedBreaks: (Vector3|Vector2)[] = []
    /** Generated directional lights for each cascade */
    lights: DirectionalLight[] = []


    constructor(enabled = true) {
        super()
        this.injectInclude()
        this._lastEnabled = enabled
        this.enabled = enabled

        this.mainFrustum = new CSMFrustum()
    }

    private _lightRef: CascadedShadowsPlugin['light']|undefined = undefined
    private _lightUpdate = (e: IObject3DEventMap['objectUpdate'])=>{
        if (e?.object !== this.light) return
        this.refreshLights(e)
    }

    /**
     * Configure shadow parameters for a specific light
     * @param params - CSM light configuration parameters
     * @param light - Target light (uses attached light if not specified)
     */
    setLightParams(params: CSMLightData, light?: DirectionalLight&IObject3D) {
        light = light || this.light
        if (!light) {
            this._viewer?.console.warn('CascadedShadowsPlugin: No light attached to CascadedShadowsPlugin')
            return
        }
        let userData = light.userData[CascadedShadowsPlugin.PluginType] as CSMLightData|undefined
        if (!userData) {
            userData = {}
            light.userData[CascadedShadowsPlugin.PluginType] = userData
        }
        Object.assign(userData, params)

        if (light === this.light)
            this.refreshLights()
    }

    refreshLights = (e?: any) => {
        if (this._lightRef && this._lightRef !== this.light) {
            this._lightRef.removeEventListener('objectUpdate', this._lightUpdate)
            if (this._lightAutoAttached) this._lightAutoAttached = false
            this._lightRef = undefined
        }

        if (!this.light) {
            return
        }

        if (!this._lightRef) {
            this.light.addEventListener('objectUpdate', this._lightUpdate)
            this._lightRef = this.light
        }

        if (this.isDisabled()) return

        this.light.castShadow = false // todo this will be set as false in gltf then
        this.light.visible = false // todo this will be set as false in gltf then
        this.parent.visible = true


        let userData = this.light.userData[CascadedShadowsPlugin.PluginType] as CSMLightData|undefined
        if (!userData) {
            userData = {}
            this.light.userData[CascadedShadowsPlugin.PluginType] = userData
        }
        const data = {
            ...defaultData,
            ...userData,
        }

        for (let i = 0; i < data.cascades; i++) {

            if (!this.lights[i]) {
                const light = new DirectionalLight(0xffffff, 1)
                light.name = 'CSM Light ' + i
                this.lights.push(light)
                this.parent.add(light)
                this.parent.add(light.target)
            }
            const light = this.lights[i]

            light.intensity = this.light.intensity
            light.color.set(this.light.color)
            light.castShadow = true
            light.shadow.mapSize.width = data.shadowMapSize
            light.shadow.mapSize.height = data.shadowMapSize

            light.shadow.camera.near = data.lightNear ?? this.light.shadow.camera.near
            light.shadow.camera.far = data.lightFar ?? this.light.shadow.camera.far
            light.shadow.bias = data.shadowBias ?? this.light.shadow.bias
            light.shadow.normalBias = this.light.shadow.normalBias
            light.shadow.radius = this.light.shadow.radius
            // todo blurSamples? anything else?

        }

        if (this.lights.length > data.cascades) {
            const remove = this.lights.splice(data.cascades, this.lights.length - data.cascades)
            for (const light of remove) {
                this.parent.remove(light.target)
                this.parent.remove(light)
            }
        }

        const changeKey = e?.change ?? e?.key
        if (!changeKey && ![
            'intensity', 'castShadow', 'mapSize', 'bias', 'radius', 'shadow', 'deserialize',
        ].includes(changeKey)) this.cameraNeedsUpdate()
    }

    private _mainCameraChange = (event: ISceneEventMap['mainCameraChange']) => {
        this.camera = event.camera
        this.cameraNeedsUpdate()
    }

    private _cameraUpdated = false
    private _mainCameraUpdate = (event: ISceneEventMap['mainCameraUpdate']) => {
        if (event.projectionUpdated !== false) this.cameraNeedsUpdate()
        else this.setDirty()
    }
    cameraNeedsUpdate = () => {
        this._cameraUpdated = true
        this._viewer?.setDirty()
    }

    private _needsUpdate = false
    private _lastEnabled: boolean
    setDirty = () => {
        const enabled = !this.isDisabled()
        if (enabled !== this._lastEnabled) {
            this._lastEnabled = enabled
            this.refreshLights()
            if (!enabled) {
                if (this.light) {
                    this.light.castShadow = true
                    this.light.visible = true
                    this.parent.visible = false
                    this.light.setDirty && this.light.setDirty()
                }
                this.extendedBreaks.length = 0
                this._sversion++
                this.materialExtension.setDirty && this.materialExtension.setDirty()
                this._viewer?.setDirty()
            }
        }
        if (!enabled) return
        this._needsUpdate = true
        this._viewer?.setDirty()
    }

    protected _viewerListeners = {
        preRender: () => {
            if (this.isDisabled() || !this.light) return
            let updated = false
            if (/* this.camera?.isOrthographicCamera || */this._cameraUpdated) {
                // updateOrthoCamera()
                this._updateFrustums()
                updated = true
                // if (params.autoUpdateHelper) {
                //     csmHelper.update()
                // }
            } else {
                // if (params.autoUpdateHelper) {
                //     csmHelper.update()
                // }
            }
            if (this._needsUpdate && !this.update()) updated = true
            if (updated) this._viewer?.renderManager.resetShadows()
        },
    }

    onAdded(viewer: ThreeViewer) {
        super.onAdded(viewer)
        viewer.scene.addEventListener('mainCameraChange', this._mainCameraChange)
        viewer.scene.addEventListener('mainCameraUpdate', this._mainCameraUpdate)
        viewer.renderManager.addEventListener('resize', this.cameraNeedsUpdate)
        this.camera = viewer.scene.mainCamera
        viewer.materialManager.registerMaterialExtension(this.materialExtension)

        viewer.object3dManager.addEventListener('lightAdd', this.refreshAttachedLight)
        viewer.object3dManager.addEventListener('lightRemove', this.refreshAttachedLight)
        this.refreshAttachedLight()

        viewer.scene.addObject(this.parent, {addToRoot: true, indexInParent: 0}) // we need to be before modelRoot so other lights dont interfere in the shader
        // this.parent = viewer.scene
        this.refreshLights()
    }

    onRemove(viewer: ThreeViewer) {
        viewer.scene.removeEventListener('mainCameraChange', this._mainCameraChange)
        viewer.scene.removeEventListener('mainCameraUpdate', this._mainCameraUpdate)
        viewer.renderManager.removeEventListener('resize', this.cameraNeedsUpdate)
        viewer.materialManager.unregisterMaterialExtension(this.materialExtension)

        viewer.object3dManager.removeEventListener('lightAdd', this.refreshAttachedLight)
        viewer.object3dManager.removeEventListener('lightRemove', this.refreshAttachedLight)
        this.refreshAttachedLight()
        if (this.light && this._lightAutoAttached) {
            this.light = undefined
            this._lightAutoAttached = false
        }

        for (const light of this.lights) {
            // todo dispose shadowmaps
            this.parent.remove(light.target)
            this.parent.remove(light)
        }
        this.parent.clear()
        this.parent.removeFromParent()

        this.camera = undefined

        super.onRemove(viewer)
    }

    protected _initCascades(breaks: number[]) {
        const camera = this.camera
        if (!camera) return this.frustums
        camera.updateProjectionMatrix && camera.updateProjectionMatrix() // this is not needed actually
        this.mainFrustum.setFromProjectionMatrix(camera.projectionMatrix, this.maxFar)
        this.mainFrustum.split(breaks, this.frustums)

        return this.frustums
    }

    protected _updateShadowBounds() {

        for (let i = 0; i < this.frustums.length; i++) {

            const light = this.lights[i]
            const shadowCam = light.shadow.camera
            const frustum = this.frustums[i]

            // Get the two points that represent that furthest points on the frustum assuming
            // that's either the diagonal across the far plane or the diagonal across the whole
            // frustum itself.
            const nearVerts = frustum.vertices.near
            const farVerts = frustum.vertices.far
            const point1 = farVerts[0]
            let point2
            if (point1.distanceTo(farVerts[2]) > point1.distanceTo(nearVerts[2])) {

                point2 = farVerts[2]

            } else {

                point2 = nearVerts[2]

            }

            let squaredBBWidth = point1.distanceTo(point2)
            if (this.fade && this.camera) {

                // expand the shadow extents by the fade margin if fade is enabled.
                const camera = this.camera
                const far = Math.max(camera.far, this.maxFar)
                const linearDepth = frustum.vertices.far[0].z / (far - camera.near)
                const margin = 0.25 * Math.pow(linearDepth, 2.0) * (far - camera.near)

                squaredBBWidth += margin

            }

            shadowCam.left = -squaredBBWidth / 2
            shadowCam.right = squaredBBWidth / 2
            shadowCam.top = squaredBBWidth / 2
            shadowCam.bottom = -squaredBBWidth / 2
            shadowCam.updateProjectionMatrix()

        }

    }

    protected _getBreaks(cascades: number) {
        this.breaks.length = 0

        const camera = this.camera
        if (!camera) return this.breaks

        const far = Math.min(camera.far, this.maxFar)

        let mode = this.mode
        if (mode === 'custom' && this.customSplitsCallback === undefined) {
            console.error('CSM: Custom split scheme callback not defined.')
            mode = 'practical'
        }


        switch (mode) {

        case 'uniform':
            this._uniformSplit(cascades, camera.near, far, this.breaks)
            break
        case 'logarithmic':
            this._logarithmicSplit(cascades, camera.near, far, this.breaks)
            break
        case 'practical':
        default:
            this._practicalSplit(cascades, camera.near, far, 0.5, this.breaks)
            break
        case 'custom':
            if (this.customSplitsCallback) {
                this.customSplitsCallback(cascades, camera.near, far, this.breaks)
            }
            break

        }

        return this.breaks
    }

    /**
     * Uniform split function for shadow cascades
     */
    private _uniformSplit(amount: number, near: number, farValue: number, target: number[]): void {
        for (let i = 1; i < amount; i++) {
            target.push((near + (farValue - near) * i / amount) / farValue)
        }
        target.push(1)
    }

    /**
     * Logarithmic split function for shadow cascades
     */
    private _logarithmicSplit(amount: number, near: number, farValue: number, target: number[]): void {
        for (let i = 1; i < amount; i++) {
            target.push(near * (farValue / near) ** (i / amount) / farValue)
        }
        target.push(1)
    }

    /**
     * Practical split function for shadow cascades
     */
    private _practicalSplit(amount: number, near: number, farValue: number, lambda: number, target: number[]): void {
        this._uniformArray.length = 0
        this._logArray.length = 0
        this._logarithmicSplit(amount, near, farValue, this._logArray)
        this._uniformSplit(amount, near, farValue, this._uniformArray)

        for (let i = 1; i < amount; i++) {
            target.push(MathUtils.lerp(this._uniformArray[i - 1], this._logArray[i - 1], lambda))
        }
        target.push(1)
    }

    private _lastCenters: Vector3[] = []

    update() {
        this._needsUpdate = false
        const camera = this.camera
        if (!camera || !this.light) return true

        const frustums = this.frustums

        const {
            shadowMapSize = defaultData.shadowMapSize,
            lightMargin = defaultData.lightMargin,
        } = this.light.userData[CascadedShadowsPlugin.PluginType] as CSMLightData|undefined || {}

        {
            this.light.updateMatrixWorld()
            const lightPos = this.light.getWorldPosition(this._center)
            // const lightPos = this._center.copy(this.light.position)
            this.light.target.updateMatrixWorld()
            this.light.target.getWorldPosition(this._lightDirection)
            // this._lightDirection.copy(this.light.target.position)

            // console.log(lightPos, this._lightDirection, this.light)

            // for each frustum we need to find its min-max box aligned with the light orientation
            // the position in lightOrientationMatrix does not matter, as we transform there and back
            this._lightOrientationMatrix.lookAt(lightPos, this._lightDirection, this._up)
            this._lightOrientationMatrixInverse.copy(this._lightOrientationMatrix).invert()

            this._lightDirection.sub(lightPos).normalize()
        }

        const centers = []
        for (let i = 0; i < frustums.length; i++) {

            const light = this.lights[i]
            const shadowCam = light.shadow.camera
            const texelWidth = (shadowCam.right - shadowCam.left) / shadowMapSize
            const texelHeight = (shadowCam.top - shadowCam.bottom) / shadowMapSize
            this._cameraToLightMatrix.multiplyMatrices(this._lightOrientationMatrixInverse, camera.matrixWorld)
            frustums[i].toSpace(this._cameraToLightMatrix, this._lightSpaceFrustum)

            const nearVerts = this._lightSpaceFrustum.vertices.near
            const farVerts = this._lightSpaceFrustum.vertices.far
            this._bbox.makeEmpty()
            for (let j = 0; j < 4; j++) {

                this._bbox.expandByPoint(nearVerts[j])
                this._bbox.expandByPoint(farVerts[j])

            }

            this._bbox.getCenter(this._center)
            this._center.z = this._bbox.max.z + lightMargin
            this._center.x = Math.floor(this._center.x / texelWidth) * texelWidth
            this._center.y = Math.floor(this._center.y / texelHeight) * texelHeight
            this._center.applyMatrix4(this._lightOrientationMatrix)

            centers.push(this._center.clone())
            light.position.copy(this._center)
            light.target.position.copy(this._center).add(this._lightDirection)

        }
        let same = true
        if (centers.length === this._lastCenters.length) {
            for (let i = 0; i < centers.length; i++) {
                if (Math.abs(centers[i].x - this._lastCenters[i].x) + Math.abs(centers[i].y - this._lastCenters[i].y) + Math.abs(centers[i].z - this._lastCenters[i].z) > 0.001) {
                    same = false
                    break
                }
            }
        } else same = false
        this._lastCenters = centers
        return same
    }

    private _lightAutoAttached = false

    /**
     * Finds and attaches to the first directional light in the scene that casts shadows
     */
    @uiButton() refreshAttachedLight = () => {
        if (this.light && this._lightAutoAttached) {
            if (!this.light.parent) {
                this.light = undefined
                this._lightAutoAttached = false
            }
            return
        }
        if (!this.attachToFirstLight) return
        const objects = this._viewer?.object3dManager.getLights() || []
        for (const obj of objects) {
            if (obj.isDirectionalLight && obj.castShadow) {
                if (obj as any === this.light) return
                this.light = obj as DirectionalLight2
                this._lightAutoAttached = true
                return
            }
        }
    }

    private _sversion = 0
    protected _updateFrustums = () => {
        if (!this.light) return

        const {
            cascades = defaultData.cascades,
        } = this.light.userData[CascadedShadowsPlugin.PluginType] as CSMLightData|undefined || {}

        const breaks = this._getBreaks(cascades)
        /* const frustums = */this._initCascades(breaks)
        this._updateShadowBounds()

        // Compute and cache extended breaks for material extension
        this.extendedBreaks.length = 0
        for (let i = 0; i < breaks.length; i++) {
            const amount = breaks[i]
            this.extendedBreaks.push(new Vector2(breaks[i - 1] || 0, amount/* , cascades + 0.1*/)) // setting total cascades for that light so it can be used in shader in the future
        }

        // this.updateUniforms()
        this._sversion++
        this.materialExtension.setDirty && this.materialExtension.setDirty()
        this._cameraUpdated = false
        this.setDirty()
    }

    /**
     * Total cascades
     */
    get cascades() {
        if (this.isDisabled() || !this.light) return 0
        return this.frustums.length
    }

    materialExtension: MaterialExtension = {
        extraDefines: {
            ['CSM_CASCADES']: () => this.cascades.toString(),
            ['USE_CSM']: ()=>this.light && !this.isDisabled() ? '1' : undefined,
            ['CSM_FADE']: () => this.fade ? '1' : undefined,
        },
        extraUniforms: {
            // ['CSM_cascades']: {value: []},
            // ['cameraNear']: ()=>({value: this.camera?.near ?? 0.01}), // todo test dynamic prop
            // ['shadowFar']: ()=>{
            //     console.log('update uniform')
            //     return {value: Math.min(this.camera?.far ?? 1000, this.maxFar)}
            // },
        },
        computeCacheKey: () => {
            return (this.isDisabled() ? '1' : '0') + this.lights.length + (this.fade ? '1' : '0') + this.light?.uuid
        },
        // shaderExtender: (shader) => {
        //     // console.log('shader extend')
        //     // if (!shader.uniforms.CSM_cascades) shader.uniforms.CSM_cascades = {value: []}
        //     // this.getExtendedBreaks(shader.uniforms.CSM_cascades.value)
        // },
        onObjectRender: (_, material) => {
            if (material.extraUniformsToUpload.CSM_cascades) material.extraUniformsToUpload.CSM_cascades.needsUpdate = false
            if (material.extraUniformsToUpload.cameraNear) material.extraUniformsToUpload.cameraNear.needsUpdate = false
            if (material.extraUniformsToUpload.shadowFar) material.extraUniformsToUpload.shadowFar.needsUpdate = false

            if (this.isDisabled() || !this.light) return

            if (!material.extraUniformsToUpload) material.extraUniformsToUpload = {}

            if (!material.extraUniformsToUpload.CSM_cascades) material.extraUniformsToUpload.CSM_cascades = {value: []}
            if (!material.extraUniformsToUpload.cameraNear) material.extraUniformsToUpload.cameraNear = {value: 0}
            if (!material.extraUniformsToUpload.shadowFar) material.extraUniformsToUpload.shadowFar = {value: 0}

            if (!(material as any).__csmVersion) (material as any).__csmVersion = 0
            if ((material as any).__csmVersion === this._sversion) return
            ;(material as any).__csmVersion = this._sversion
            material.extraUniformsToUpload.cameraNear.value = this.camera?.near ?? 0.01
            material.extraUniformsToUpload.shadowFar.value = Math.min(this.camera?.far ?? 1000, this.maxFar)
            material.extraUniformsToUpload.CSM_cascades.value = this.extendedBreaks// .map(v=>v.clone())

            material.extraUniformsToUpload.cameraNear.needsUpdate = true
            material.extraUniformsToUpload.shadowFar.needsUpdate = true
            material.extraUniformsToUpload.CSM_cascades.needsUpdate = true
        },

        isCompatible: (material: any) => {
            return material.isMeshStandardMaterial || material.isMeshPhysicalMaterial || material.isMeshLambertMaterial || material.isMeshPhongMaterial
        },
    }

    uiConfig: UiObjectConfig = {
        type: 'folder',
        label: 'Cascaded Shadows (CSM)',
        children: [
            ...generateUiConfig(this),
            {
                type: 'button',
                label: ()=>this.light ? 'Select Light' : 'No Light Attached',
                disabled: ()=>!this.light,
                onClick: ()=>{
                    if (!this.light) return
                    this.light.dispatchEvent({type: 'select', ui: true, value: this.light, object: this.light})
                },
            },
        ],
    }

    // todo in three.js r166 update, add shadow intensity in shader calls
    injectInclude() {
        // for hot reload etc
        if (ShaderChunk.lights_fragment_begin.includes('defined( USE_CSM ) && defined( CSM_CASCADES )')) return

        // ShaderChunk.lights_fragment_begin = CSMShader.lights_fragment_begin
        ShaderChunk.lights_fragment_begin = shaderReplaceString(
            ShaderChunk.lights_fragment_begin,
            '#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct )\n', `
#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct ) && defined( USE_CSM ) && defined( CSM_CASCADES )

	DirectionalLight directionalLight;
	float linearDepth = (vViewPosition.z) / (shadowFar - cameraNear);
	#if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0
	DirectionalLightShadow directionalLightShadow;
	#endif

	#if defined( USE_SHADOWMAP ) && defined( CSM_FADE )
		vec2 cascade;
		float cascadeCenter;
		float closestEdge;
		float margin;
		float csmx;
		float csmy;

		#pragma unroll_loop_start
		for ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {

			directionalLight = directionalLights[ i ];
			getDirectionalLightInfo( directionalLight, directLight );

			#if ( UNROLLED_LOOP_INDEX < NUM_DIR_LIGHT_SHADOWS )
			
            #if ( UNROLLED_LOOP_INDEX < CSM_CASCADES )

				// NOTE: Depth gets larger away from the camera.
				// cascade.x is closer, cascade.y is further
				cascade = CSM_cascades[ UNROLLED_LOOP_INDEX ];
				cascadeCenter = ( cascade.x + cascade.y ) / 2.0;
				closestEdge = linearDepth < cascadeCenter ? cascade.x : cascade.y;
				margin = 0.25 * pow( closestEdge, 2.0 );
				csmx = cascade.x - margin / 2.0;
				csmy = cascade.y + margin / 2.0;
				if( linearDepth >= csmx && ( linearDepth < csmy || UNROLLED_LOOP_INDEX == CSM_CASCADES - 1 ) ) {

					float dist = min( linearDepth - csmx, csmy - linearDepth );
					float ratio = clamp( dist / margin, 0.0, 1.0 );

					vec3 prevColor = directLight.color;
					directionalLightShadow = directionalLightShadows[ i ];
					directLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;

					bool shouldFadeLastCascade = UNROLLED_LOOP_INDEX == CSM_CASCADES - 1 && linearDepth > cascadeCenter;
					directLight.color = mix( prevColor, directLight.color, shouldFadeLastCascade ? ratio : 1.0 );

					ReflectedLight prevLight = reflectedLight;
					RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );

					bool shouldBlend = UNROLLED_LOOP_INDEX != CSM_CASCADES - 1 || UNROLLED_LOOP_INDEX == CSM_CASCADES - 1 && linearDepth < cascadeCenter;
					float blendRatio = shouldBlend ? ratio : 1.0;

					reflectedLight.directDiffuse = mix( prevLight.directDiffuse, reflectedLight.directDiffuse, blendRatio );
					reflectedLight.directSpecular = mix( prevLight.directSpecular, reflectedLight.directSpecular, blendRatio );
					reflectedLight.indirectDiffuse = mix( prevLight.indirectDiffuse, reflectedLight.indirectDiffuse, blendRatio );
					reflectedLight.indirectSpecular = mix( prevLight.indirectSpecular, reflectedLight.indirectSpecular, blendRatio );

				}
				
				
            #else 
            
                directionalLightShadow = directionalLightShadows[ i ];
                directLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;
    
                RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );

			#endif
			
			#endif

		}
		#pragma unroll_loop_end
	#elif defined (USE_SHADOWMAP)

		#pragma unroll_loop_start
		for ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {

			directionalLight = directionalLights[ i ];
			getDirectionalLightInfo( directionalLight, directLight );

			#if ( UNROLLED_LOOP_INDEX < NUM_DIR_LIGHT_SHADOWS )

                directionalLightShadow = directionalLightShadows[ i ];
				
				#if ( UNROLLED_LOOP_INDEX < CSM_CASCADES )

					if(linearDepth >= CSM_cascades[UNROLLED_LOOP_INDEX].x && linearDepth < CSM_cascades[UNROLLED_LOOP_INDEX].y) directLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;

					if(linearDepth >= CSM_cascades[UNROLLED_LOOP_INDEX].x && (linearDepth < CSM_cascades[UNROLLED_LOOP_INDEX].y || UNROLLED_LOOP_INDEX == CSM_CASCADES - 1)) RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );

				#else

					directLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;

					RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );

				#endif
				
			#endif

		}
		#pragma unroll_loop_end

	#elif ( NUM_DIR_LIGHT_SHADOWS > 0 )
		// note: no loop here - all CSM lights are in fact one light only
		getDirectionalLightInfo( directionalLights[0], directLight );
		RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );

	#endif

	#if ( NUM_DIR_LIGHTS > NUM_DIR_LIGHT_SHADOWS)
		// compute the lights not casting shadows (if any)

		#pragma unroll_loop_start
		for ( int i = NUM_DIR_LIGHT_SHADOWS; i < NUM_DIR_LIGHTS; i ++ ) {

			directionalLight = directionalLights[ i ];

			getDirectionalLightInfo( directionalLight, directLight );

			RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );

		}
		#pragma unroll_loop_end

	#endif

#endif


#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct ) && !( defined( USE_CSM ) && defined( CSM_CASCADES ) )
`)
        // ShaderChunk.lights_pars_begin = CSMShader.lights_pars_begin
        ShaderChunk.lights_pars_begin = `
#if defined( USE_CSM ) && defined( CSM_CASCADES )
uniform vec2 CSM_cascades[CSM_CASCADES];
uniform float cameraNear;
uniform float shadowFar;
#endif
	` + ShaderChunk.lights_pars_begin

    }

    // temp variables
    private readonly _lightDirection = new Vector3()
    private readonly _cameraToLightMatrix = new Matrix4()
    private readonly _lightSpaceFrustum = new CSMFrustum()
    private readonly _center = new Vector3()
    private readonly _bbox = new Box3()
    private readonly _uniformArray: number[] = []
    private readonly _logArray: number[] = []
    private readonly _lightOrientationMatrix = new Matrix4()
    private readonly _lightOrientationMatrixInverse = new Matrix4()
    private readonly _up = new Vector3(0, 1, 0)

}

export interface FrustumParams {
    projectionMatrix?: Matrix4;
    maxFar?: number;
}

export interface FrustumVertices {
    far: Vector3[];
    near: Vector3[]
}

export class CSMFrustum {
    private _inverseProjectionMatrix = new Matrix4()

    public vertices: FrustumVertices = {
        near: [
            new Vector3(),
            new Vector3(),
            new Vector3(),
            new Vector3(),
        ],
        far: [
            new Vector3(),
            new Vector3(),
            new Vector3(),
            new Vector3(),
        ],
    }

    public constructor(data: FrustumParams = {}) {

        if (data.projectionMatrix !== undefined) {

            this.setFromProjectionMatrix(data.projectionMatrix, data.maxFar || 10000)

        }

    }

    public setFromProjectionMatrix(projectionMatrix: Matrix4, maxFar: number): FrustumVertices {

        const isOrthographic = projectionMatrix.elements[ 2 * 4 + 3 ] === 0

        this._inverseProjectionMatrix.copy(projectionMatrix).invert()

        // 3 --- 0  vertices.near/far order
        // |     |
        // 2 --- 1
        // clip space spans from [-1, 1]

        this.vertices.near[ 0 ].set(1, 1, -1)
        this.vertices.near[ 1 ].set(1, -1, -1)
        this.vertices.near[ 2 ].set(-1, -1, -1)
        this.vertices.near[ 3 ].set(-1, 1, -1)
        this.vertices.near.forEach((v) => {

            v.applyMatrix4(this._inverseProjectionMatrix)

        })

        this.vertices.far[ 0 ].set(1, 1, 1)
        this.vertices.far[ 1 ].set(1, -1, 1)
        this.vertices.far[ 2 ].set(-1, -1, 1)
        this.vertices.far[ 3 ].set(-1, 1, 1)
        this.vertices.far.forEach((v) => {

            v.applyMatrix4(this._inverseProjectionMatrix)

            const absZ = Math.abs(v.z)
            if (isOrthographic) {

                v.z *= Math.min(maxFar / absZ, 1.0)

            } else {

                v.multiplyScalar(Math.min(maxFar / absZ, 1.0))

            }

        })

        return this.vertices

    }

    public split(breaks: number[], target: CSMFrustum[]) {

        while (breaks.length > target.length) {

            target.push(new CSMFrustum())

        }

        target.length = breaks.length

        for (let i = 0; i < breaks.length; i++) {

            const cascade = target[ i ]

            if (i === 0) {

                for (let j = 0; j < 4; j++) {

                    cascade.vertices.near[ j ].copy(this.vertices.near[ j ])

                }

            } else {

                for (let j = 0; j < 4; j++) {

                    cascade.vertices.near[ j ].lerpVectors(this.vertices.near[ j ], this.vertices.far[ j ], breaks[ i - 1 ])

                }

            }

            if (i === breaks.length - 1) {

                for (let j = 0; j < 4; j++) {

                    cascade.vertices.far[ j ].copy(this.vertices.far[ j ])

                }

            } else {

                for (let j = 0; j < 4; j++) {

                    cascade.vertices.far[ j ].lerpVectors(this.vertices.near[ j ], this.vertices.far[ j ], breaks[ i ])

                }

            }

        }

    }

    public toSpace(cameraMatrix: Matrix4, target: CSMFrustum) {

        for (let i = 0; i < 4; i++) {

            target.vertices.near[ i ]
                .copy(this.vertices.near[ i ])
                .applyMatrix4(cameraMatrix)

            target.vertices.far[ i ]
                .copy(this.vertices.far[ i ])
                .applyMatrix4(cameraMatrix)

        }

    }

}
