import * as THREE from './three.module.js'
import { OrbitControls } from './OrbitControls.module.js'
import * as TWEEN from './tween.module.js'

export class Threetobus{

    constructor(options){
        this._curEventsMapping = []
        this._stagedEventsMapping = options.eventsMapping
        this.sceneSize = options.sceneSize
        this.commitConfig()

        this.cameras = {}
        this.renderers = []
        this.tweensRegistry = {}
        app.events.addEvent('MessageBus.Connected', this.busReconnect.bind(this), 'threetobus')

    }

    busReconnect(){
        this.commitConfig() // To resubscribe...
        //TODO :  Not ideal because if we're in the middle of non-commited changes...
    }

    get EventsMapping() { return this._stagedEventsMapping }
    get liveEventsMapping() { return this._curEventsMapping }
    set EventsMapping(newConfig) { this._stagedEventsMapping = newConfig }

    async commitConfig(){
        const chansToAdd = []
        const chansToKeep = []
        for(const chanObj of this._stagedEventsMapping){
            if(this._curEventsMapping.map(item => item.chan).includes(chanObj.chan)) chansToKeep.push(chanObj)
            else chansToAdd.push(chanObj)
        }
        const chansToDel = this._curEventsMapping.filter(item => (!chansToKeep.map(c=>c.chan).includes(item.chan) && !chansToAdd.map(c=>c.chan).includes(item.chan)))
        await app.MessageBus.subscribe(chansToAdd.map(item => item.chan))
        await app.MessageBus.unSubscribe(chansToDel.map(item => item.chan))
        // console.log('subscribe:', chansToAdd.map(item => item.chan))
        // console.log('unSubscribe:', chansToDel)
  
        const eventsToAdd = chansToAdd.flatMap(item => item.events.map(ev => ({ chan:item.chan, eventName:ev.eventName })))
        let eventsToDel = []//= chansToDel.flatMap(item => item.events.map(ev => ({ chan:item.chan, eventName:ev.eventName })))
        for(const oldChan of this._curEventsMapping){
            for(const oldEvent of oldChan.events){
                for(const keepChan of chansToKeep){
                    if(!keepChan.events.map(item=>item.eventName).includes(oldEvent.eventName)) eventsToDel.push({chan: oldChan.chan, eventName: oldEvent.eventName})
                }
            }
        }
        
        // console.log('eventsToAdd:', eventsToAdd)
        // console.log('eventsToDel:', eventsToDel)
        for(const eventToAdd of eventsToAdd){
            app.MessageBus.addBusListener(eventToAdd.eventName, [eventToAdd.chan], this.processBusEvent.bind(this, eventToAdd.eventName,), 'threetobus')
        }
        for(const eventToDel of eventsToDel){
            app.MessageBus.removeBusListener(eventToDel.eventName, this.processBusEvent.bind(this, eventToDel.eventName), 'threetobus')
        }

        this._curEventsMapping = this.deepClone(this._stagedEventsMapping)
    }
    
    deepClone(obj) { // Needed because structuredClone doesn't take functions (and we have transformers)
        if (obj === null || typeof obj !== 'object') {
            return obj
        }
        if (Array.isArray(obj)) {
            return obj.map((el => this.deepClone(el)))
        }
        const clone = {}
        for (const key in obj) {
            clone[key] = this.deepClone(obj[key])
        }
        return clone
    }


    processBusEvent(eventType, chan, payload, userId, x){
        const chanObj = this._curEventsMapping.find(item =>  app.MessageBus.chanMatch(chan,  item.chan))
        if(!chanObj) { console.warn('Not a configured chan!'); return }
        const eventObj = chanObj.events.find(item => item.eventName==eventType)
        if(!eventObj) { console.warn('Not a configured event!'); return }
        for(const mapping of eventObj.mappings){
            let id = this.getValueByPath(payload, mapping.id)
            //TODO Child selection is static in mapping... does it make sense to also have the event select the child ?
            // if yes : how to discriminate static value from event-mapping definition ?
            if(mapping.child) id += '_'+mapping.child
            if(id){
                const obj3D = this.scene.getObjectByName(id)
                this.assignFromConfig(payload, mapping, obj3D)
            }
        }
    }

    assignFromConfig(payload, mapping, obj3D) {  
        const tweenProps = { 
            position: {
                props: {},
                method: this.smoothMove.bind(this),
            }, 
            rotation:{
                props: {},
                method: this.smoothRotate.bind(this),
            }, 
        }
        for (const [path, rule] of Object.entries(mapping.assign)) {
            let value 
            if(typeof rule === 'string') { // plain path                
                value= this.getValueByPath(payload, rule)
            } else if((typeof(rule) == 'object') && (typeof(rule.transformer) == 'function')) { // transformer
                const fnargs = (rule.arguments || []).map(arg => this.getValueByPath(payload,arg))
                value = rule.transformer(...fnargs)
            }
            if(value !== undefined) {
                if(mapping.tween){
                    if(path.startsWith('position.')){ 
                        tweenProps.position.props[path.substring(9)] = value
                    } else if(path.startsWith('rotation.')){
                        tweenProps.rotation.props[path.substring(9)] = value
                    }
                } else { 
                    this.setProp(obj3D, path, value)
                }
            } // else console.warn('Could not get value from rule:',rule)
        }
        if(mapping.tween){
            for(const tweenGroup in tweenProps){ 
                if((Object.keys(tweenProps[tweenGroup].props).length>0) 
                    && (typeof(tweenProps[tweenGroup].method)=='function')){
                    tweenProps[tweenGroup].props.object = obj3D
                    tweenProps[tweenGroup].props.delay = mapping.tweenDelay
                    tweenProps[tweenGroup].method(tweenProps[tweenGroup].props)                    
                } // else { console.log('avoided tween', tweenGroup)}
            }
        }
    }

    setProp(obj3D, path, value) { 
        const parts = path.split('.')
        let target = obj3D
        for (let i = 0; i < parts.length - 1; i++) {
            target = target[parts[i]]
            if (!target) return // path broken
        }
        const last = parts[parts.length - 1]
        
        // Handle Three.Color objects
        if (target[last] && target[last].isColor) {
            target[last].set(value)
        } else {
            target[last] = value
        }
    }

    getValueByPath(obj, path) {
        return(path.split('.').reduce((acc, key) => acc?.[key], obj))
    }

    initScene(options){
        // Scene
        this.scene = new THREE.Scene()

        if(options.grid){
            this.grid = new THREE.GridHelper(this.sceneSize.x, this.sceneSize.y, 0x8888AA, 0x8888AA)
            this.grid.layers.set(1)
            this.scene.add(this.grid)        
        }
        if(options.axes){
            this.axes = new THREE.AxesHelper(this.sceneSize.x/2, this.sceneSize.y/2)
            this.axes.layers.set(2)
            this.scene.add(this.axes)        
        }

        // Cameras
        this.cameras.camPerspective = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
        this.cameras.camPerspective.position.set(3, 3, 5)
        this.cameras.camPerspective.layers.enable(1)
        this.cameras.camPerspective.layers.enable(2)
        const aspect = window.innerWidth / window.innerHeight
        const frustumSize = 10 
        this.cameras.cam2Dtop = new THREE.OrthographicCamera(
            -frustumSize * aspect / 2,
            frustumSize * aspect / 2,
            frustumSize / 2,
            -frustumSize / 2,
            0.1,
            1000
        ) 
        this.cameras.cam2Dtop.position.set(0, 100, 0)
        this.cameras.cam2Dtop.lookAt(0, 0, 0)
        this.cameras.cam2Dtop.layers.enable(1)

        // Lights
        const light = new THREE.DirectionalLight(0xffffff, 1)
        light.position.set(5, 5, 5)
        light.intensity = 2
        this.scene.add(light)
        this.scene.add(new THREE.AmbientLight(0xffffff, 0.4))

    }

    startRendering(canvasEl, mode){
        let renderEngine
        if(mode=='2D'){
            renderEngine = new RenderingEngine(canvasEl, this.scene, this.cameras.cam2Dtop, mode)
         } else if(mode=='3D') {
            renderEngine = new RenderingEngine(canvasEl, this.scene, this.cameras.camPerspective, mode)
        } else console.error('Unknown rendering mode !')
        renderEngine.addControls()       
        renderEngine.render()
        this.renderers.push(renderEngine)
        return(renderEngine)
    }

    agentFromJSON(id, desc){ 
        let obj
        if(desc.type === 'Mesh') {
            const geom = new THREE[desc.geometry.type](...(desc.geometry.args || []))

            const matType = desc.material.type
            const matProps = { ...desc.material }
            for (const key in matProps) {
                if (key === 'type') continue
                if (typeof matProps[key] === 'string' && THREE[matProps[key]] !== undefined) {
                    matProps[key] = THREE[matProps[key]]
                }
            }
            // convert color strings like "0xffffaa" to numbers
            if (typeof matProps.color === 'string' && matProps.color.startsWith('0x')) {
                matProps.color = parseInt(matProps.color)
            }
            const mat = new THREE[matType](matProps)

            obj = new THREE.Mesh(geom, mat)
        } else if(desc.type === 'Group') {
            obj = new THREE.Group()
        } else {
            throw new Error("Unknown type: " + desc.type)
        }
        
        // Apply transforms
        if(desc.position) obj.position.set(...desc.position)
        if(desc.rotation) obj.rotation.set(...desc.rotation)
        if(desc.scale) obj.scale.set(...desc.scale)
                    
        // Recursively add children
        if(desc.children) {
            desc.children.forEach(childDesc => {
                const childId = (childDesc.childSuffix) ? `${id}_${childDesc.childSuffix}` : ''
                obj.add(this.agentFromJSON(childId, childDesc))
            })
        }
        obj.name = id
        this.tweensRegistry[id] = { 'move': null, 'rotate': null }
        return obj
    }

    smoothMove(options){ 
        // options:  object, dX, dY, dZ, delay, easing, easingMode
        //      Absolute: x,y,z
        //      Relative: dx,dy,dz
        //      delay: ms
        //      easings: Linear, Quadratic, Cubic, Quartic, Quintic, Sinusoidal, Exponential, Circular, Elastic, Back, Bounce
        //      easingMode:     In → starts slow, accelerates towards the end.
        //                      Out → starts fast, decelerates smoothly.
        //                      InOut → slow at both ends, faster in the middle.
        options.easing = options.easing ? options.easing : 'Quadratic'
        options.easingMode = options.easingMode ? options.easingMode : 'InOut'

        let newX = parseFloat(options.x)
        newX = isNaN(newX) ? options.object.position.x : newX
        newX +=  (parseFloat(options.dx) || 0)

        let newY = parseFloat(options.y)
        newY = isNaN(newY) ? options.object.position.y : newY
        newY +=  (parseFloat(options.dy) || 0)

        let newZ = parseFloat(options.z)
        newZ = isNaN(newZ) ? options.object.position.z : newZ
        newZ +=  (parseFloat(options.dz) || 0)

        if(this.tweensRegistry[options.object.name]['move']) this.tweensRegistry[options.object.name]['move'].end()
        this.tweensRegistry[options.object.name]['move'] = new TWEEN.Tween(options.object.position)
            .to({   x: newX, 
                    y: newY,
                    z: newZ,  
                }, options.delay)
            .easing(TWEEN.Easing[options.easing][options.easingMode])
            .onComplete(() => this.tweensRegistry[options.object.name]['move']=null)
            .start()
    }    
    
    smoothRotate(options){
        // options:  object, dX, dY, dZ, delay, easing, easingMode
        //      Absolute: x,y,z angle, in degrees
        //      Relative: dx,dy,dz angle in deg
        //      delay: ms
        //      easings: Linear, Quadratic, Cubic, Quartic, Quintic, Sinusoidal, Exponential, Circular, Elastic, Back, Bounce
        //      easingMode:     In → starts slow, accelerates towards the end.
        //                      Out → starts fast, decelerates smoothly.
        //                      InOut → slow at both ends, faster in the middle.
        options.easing = options.easing ? options.easing : 'Quadratic'
        options.easingMode = options.easingMode ? options.easingMode : 'InOut'

        let newX = parseFloat(options.x)
        newX = isNaN(newX) ? options.object.rotation.x * Math.PI / 180: newX * Math.PI / 180
        newX +=  (parseFloat(options.dx) || 0)

        let newY = parseFloat(options.y)
        newY = isNaN(newY) ? options.object.rotation.y * Math.PI / 180: newY * Math.PI / 180
        newY +=  (parseFloat(options.dy) || 0)

        let newZ = parseFloat(options.z)
        newZ = isNaN(newZ) ? options.object.rotation.z* Math.PI / 180 : newZ * Math.PI / 180
        newZ +=  (parseFloat(options.dz) || 0)

        if(this.tweensRegistry[options.object.name]['rotate']) this.tweensRegistry[options.object.name]['rotate'].end()
        this.tweensRegistry[options.object.name]['rotate'] = new TWEEN.Tween(options.object.rotation)
            .to({   x: newX, 
                    y: newY,
                    z: newZ,  
                }, options.delay)
            .easing(TWEEN.Easing[options.easing][options.easingMode])
            .onComplete(() => this.tweensRegistry[options.object.name]['rotate']=null)
            .start()
    }    
}

class RenderingEngine{
    constructor(canvasEl, scene, camera, mode){
        this.canvasEl = canvasEl
        this.scene = scene
        this.renderer = new THREE.WebGLRenderer({ antialias: true, canvas: this.canvasEl })
        this.camera = camera
        this.mode = mode
    }

    addControls(){
        this.controls = new OrbitControls(this.camera, this.canvasEl)
        if(this.mode=='2D'){
            this.controls.maxPolarAngle = 0 // Math.PI / 2
            this.controls.minPolarAngle = 0 // Math.PI / 2
        } else if(this.mode=='3D'){
        }
        
        this.controls.mouseButtons = {
            LEFT: THREE.MOUSE.ROTATE,     // keep orbit on left
            MIDDLE: THREE.MOUSE.PAN,      // pan with middle-click
            RIGHT: THREE.MOUSE.DOLLY      // zoom with right-click
        }
    }

    render() {
        TWEEN.update()
        if(this.resizeRendererToDisplaySize()) {
            this.camera.aspect = this.renderer.domElement.clientWidth / this.canvasEl.clientHeight
            this.camera.updateProjectionMatrix()
        }
        this.renderer.render(this.scene, this.camera)
        requestAnimationFrame(this.render.bind(this))
    }


    resizeRendererToDisplaySize() {
        const width = this.canvasEl.clientWidth
        const height = this.canvasEl.clientHeight
        if (this.canvasEl.width !== width || this.canvasEl.height !== height) {
            this.renderer.setSize(width, height, false)
            return true
        }
        return false
    }
}

// Make this module available to common JS
if(!app.LoadedModules) app.LoadedModules = {}
app.LoadedModules.Threetobus = Threetobus


//TODO resubscribe on connection loss & re-open