/*
Expects
  jQuery
  HOST from /js/twxchatBabylon
  BABYLON from /js/twxchatBabylon

*/

class TWXChatAvatar{

  static OptionalAnimations = {
    //Idle: 'idle', // Mandatory
    //LipSync: 'lipSync', // Mandatory
    Gesture: 'gesture', // Optional
    Emote: 'emote', // Optional
    //Face: 'face', // Mandatory
    Blink: 'blink', // Optional
    //POI: 'poi',  // Mandatory
  };

  constructor(character, voice, backgroundUrl, streamingSpeech){

    this.allCharacters = {};
    this.engine = null;
    this.scene = null;
    this.camera = null;
    this.host = null;
    this.character = character;
    this.voice = voice;
    this.backgroundUrl = backgroundUrl; // can be ''
    this._isSpeaking = false;
    this.streamingSpeech = streamingSpeech; // iOS device doest seem to work with streaming as true, thus allow set to false

    // Validate containerId
    if(jQuery('#avatar-container').length==0){
      throw new Error(`Missing avatar DOM containers`);
    }

    // Validate character
    /*
    this.#loadAllCharacters();
    if(!character in this.allCharacters){
      throw new Error(`Unkown avatar character: "${character}".`);
    }
    */
    const pattern = /^(?<provider>[^:]+):(?<name>.+)$/;
    let match = character.match(pattern);
    if (!match) {
        throw new Error(`Invalid character format: "${identifier}". Expected format is "<provider>:<name>".`);
    }
    this.characterProvider = match.groups.provider;
    this.characterName = match.groups.name;

    // Zoom Params
    this.zoomParams = {};
    this.#setupZoomParams();

    // Init voice properties
    const [voiceProvider, voiceName, voiceSpeed = ''] = voice.split(":");
    this.voiceProvider = voiceProvider.trim();
    this.voiceName = voiceName.trim();
    this.voiceSpeed = voiceSpeed.trim();

    this.serverConfig = null;

    // setup event handler
    this.eventTarget = new EventTarget();  // Create a custom event target

  }

  #setupZoomParams() {
    this.zoomParams = {
      audioAttachJoint : 'chardef_c_neckB',
      lookJoint: 'charjx_c_look',
      headShoulders: {meshName: 'char:eyes_outer', zoomLevel: 7, offsetY: 0, offsetX: 0.07}, // zoomLevel larger val = further away
      headUpperTorso: {meshName: 'char:head', zoomLevel: 4, offsetY: -0.1, offsetX: 0},
      fullyBody: {meshName: 'char:snaps', zoomLevel: 12, offsetY: 0, offsetX: 0},
    };
  }

  async #initServerConfig() {

    // setup server config
    try {
      this.serverConfig = this.#getServerConfig();
    } catch (error) {
      console.error('Error fetching avatar server config:', error);
    }
    if(this.serverConfig === null){
      console.log('Failed to get Avatar config. Aborting avatar setup.');
      return;
    }

    switch (this.characterProvider) {
      case 'aws':
          await this.#setupAWS();
          break;
      default:
          console.log('Unknown Character Provider:',this.characterProvider,'. Aborting avatar setup.');
          return;
    }
    
  }

  // Get server config
  #getServerConfig() {

    var config = null;

    // synchronously get config from server
    jQuery.ajax({
        url: twxChat.ajax_url,
        type: 'POST',
        async: false,  // This makes the request synchronous
        data: {
            action: 'twx_chat_get_avatar_config',
            nonce: twxChat.nonce,
        },
        dataType: 'json',
        success: function(response) {
          if (response.success && response.data) config = response.data;
        },
        error: function(xhr, status, error) {
            console.log('getServerConfig Error:', error);
            throw error;
        }
    });
    return config;
  }

  async #setupAWS() {
    // Validate Identity Pool ID
    if (!this.serverConfig.awsCognitoIdentityPoolId) {
        console.error('Missing AWS Identity Pool ID. Avatar speech disabled.');
        return;
    }

    // Parse region from Identity Pool ID
    const [region, ...restOfId] = this.serverConfig.awsCognitoIdentityPoolId.split(':');
    if (!region) {
        console.error('Invalid AWS Identity Pool ID:', this.serverConfig.awsCognitoIdentityPoolId);
        return;
    }

    try {
        // Configure AWS
        window.AWS.config.region = region;
        
        // Create Credentials with iOS compatibility
        const credentials = new AWS.CognitoIdentityCredentials({
            IdentityPoolId: this.serverConfig.awsCognitoIdentityPoolId
        });

        // Explicitly resolve credentials
        await credentials.getPromise();
        window.AWS.config.credentials = credentials;

        // Create Audio Context with iOS compatibility
        this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
        
        // Initialize Polly with latest version
        this.polly = new AWS.Polly();
        const presigner = new AWS.Polly.Presigner();

        // Initialize speech feature
        this.speechInit = HOST.aws.TextToSpeechFeature.initializeService(
            this.polly,
            presigner,
            window.AWS.VERSION
        );
        
        // Await initialization
        await this.speechInit;

        // Optional: Add global error handler for AWS credentials
        credentials.onError = (error) => {
            console.error('AWS Credentials Error:', error);
        };

    } catch (error) {
        console.error('AWS Setup Failed:', error);
        // Optionally disable speech or show user-friendly error
    }
  }   

  /**
   * Creates a base scene by adding environment elements, lights, and a camera
   * to the provided scene object.
   *
   * @param {BABYLON.Scene} scene An empty scene object to which elements will be
   * added.
   * @returns {object} An object with the shape:
   ```
  {
    scene: BABYLON.Scene,
    shadowGenerator: BABYLON.ShadowGenerator
  }
  */
  #setupSceneEnvironment(scene) {
    // Create a simple environment and camera
    const environmentHelper = scene.createDefaultEnvironment({
      groundOpacity: 1,
      groundShadowLevel: 0.1,
    });
    environmentHelper.setMainColor(HOST.Color3.Teal());

    scene.environmentIntensity = 1.2;

    const shadowLight = new HOST.DirectionalLight(
      'shadowLight',
      new HOST.Vector3(0.8, -2, -1)
    );
    shadowLight.diffuse = new HOST.Color3(1, 0.9, 0.62);
    shadowLight.intensity = 2;

    const keyLight = new HOST.DirectionalLight('keyLight', new HOST.Vector3(0.3, -1, -2));
    keyLight.diffuse = new HOST.Color3(1, 0.9, 0.65);
    keyLight.intensity = 3;

    // Add a camera.
    const cameraRotation = HOST.Angle.FromDegrees(85).radians();
    const cameraPitch = HOST.Angle.FromDegrees(70).radians();
    this.camera = new HOST.ArcRotateCamera(
      'camera',
      cameraRotation,
      cameraPitch,
      2.6,
      new HOST.Vector3(0, 1.0, 0)
    );
    this.camera.wheelDeltaPercentage = 0.01;
    this.camera.minZ = 0.01;

    // Initialize user control of camera.
    //const canvas = scene.getEngine().getRenderingCanvas();
    //this.camera.attachControl(canvas, true);

    // Disable user interactions  
    //this.camera.inputs.clear();  // Removes all attached input controls      

    const shadowGenerator = new HOST.ShadowGenerator(2048, shadowLight);
    shadowGenerator.useBlurExponentialShadowMap = true;
    shadowGenerator.blurKernel = 8;
    scene.meshes.forEach(mesh => {
      shadowGenerator.addShadowCaster(mesh);
    });

    return {shadowGenerator};
  }

  #createBackground() {

    let backgroundTexture = new BABYLON.Texture(this.backgroundUrl, this.scene);

    // Create a material for the background
    let backgroundMaterial = new BABYLON.StandardMaterial("backgroundMaterial", this.scene);
    backgroundMaterial.diffuseTexture = backgroundTexture;
    backgroundMaterial.backFaceCulling = false;  // Ensure the material is visible from both sides (important for background)
    
    // Create a plane mesh for the background
    let backgroundPlane = BABYLON.MeshBuilder.CreatePlane("background", { width: 10, height: 10 }, this.scene);
    
    // Assign the material to the background plane
    backgroundPlane.material = backgroundMaterial;
    
    backgroundPlane.position.x = 0;
    backgroundPlane.position.y = 0;  // You can adjust this to place the background where you want
    backgroundPlane.position.z = -2; //-5;
    
    //backgroundPlane.rotation.x = Math.PI / 2;  // Rotate the plane 90 degrees around the X-axis

  }

  #onWindowResize(container) {
    //if (!container || !this.engine || !this.camera) return;

    //let $cont = jQuery(container);
    //console.log('window resize width/height:',$cont.height());
    
    // Resize the engine
    this.engine.resize();

    //jQuery('#renderCanvas').width(container.width);
    //jQuery('#renderCanvas').height($cont.height());

    // Adjust camera aspect ratio
    this.camera.aspectRatio = container.clientWidth / container.clientHeight;
    this.camera.fov = BABYLON.Tools.ToRadians(60); // Adjust field of view if needed
    this.camera.update();


  }

  #createPOIObjectOLD(poiName, xPos, yPos, zPos) {
    try {
        // Check if the POI already exists in the scene
        const object = this.scene.getMeshByName(poiName);
        if (object) {
            this.#deletePOIObject(poiName);  // Delete the existing POI if it exists
        }

        // Create a small sphere geometry for the POI target
        //const geometry = new BABYLON.SphereGeometry(0.1, 8, 8); // Radius of 0.1
        const material = new BABYLON.StandardMaterial("poiMaterial", this.scene);
        material.alpha = 0; // Invisible material
        const object3d = new BABYLON.Mesh("poiMesh", this.scene);
        object3d.material = material;

        // Set the position of the POI based on the passed xPos, yPos, zPos
        object3d.position.set(xPos, yPos, zPos);
        object3d.name = poiName;  // Assign the POI name to the object
        
        // Add the POI to the scene
        this.scene.addMesh(object3d);

    } catch (error) {
        console.error("createPOIObject error", error);
    }
  }

  #createPOIObject(poiName, xPos, yPos, zPos) {
      try {
          // Check if the POI already exists
          let poi = this.scene.getMeshByName(poiName);

          if (poi) {
              // Update position of existing mesh
              poi.position.set(xPos, yPos, zPos);
          } else {
              // Create a visible or invisible marker sphere
              poi = BABYLON.MeshBuilder.CreateSphere(poiName, { diameter: 0.05 }, this.scene);
              poi.position.set(xPos, yPos, zPos);

              const material = new BABYLON.StandardMaterial("poiMaterial", this.scene);
              material.alpha = 0;

              // debug with 3 lines below
              //material.alpha = 1;
              //material.diffuseColor = new BABYLON.Color3(1, 1, 0); // Yellow
              //material.emissiveColor = new BABYLON.Color3(1, 1, 0); // Glow slightly              

              poi.material = material;

              poi.isPickable = false; // POI should not interfere with scene interaction
          }

      } catch (error) {
          console.error("createPOIObject error", error);
      }
  }

  #deletePOIObject(name) {
    try {
        // Find the mesh by name in the scene
        const object = this.scene.getMeshByName(name);
        if (object) {
            // Remove the object from the scene
            object.dispose(); // Dispose of the mesh and all associated resources
        } else {
            console.warn(`deletePOIObject:: Object '${name}' not found in scene.`);
        }
    } catch (error) {
        console.error("deletePOIObject error", error);
    }
  }

  async #zoomCameraToMesh(meshName, zoomLevel = 3, offsetX = 0, offsetY = 0) {
    if (!this.scene) return;

    const targetMesh = this.scene.getMeshByName(meshName);
    if (!targetMesh) {
      console.error(`Mesh "${meshName}" not found`);
      return;
    }

    const boundingInfo = targetMesh.getBoundingInfo();
    const center = boundingInfo.boundingSphere.centerWorld;
    const radius = boundingInfo.boundingSphere.radiusWorld;

    // Direction: camera looks from +Z to -Z
    const direction = new BABYLON.Vector3(0, 0, 1); 
    const baseCameraPosition = center.add(direction.scale(radius * zoomLevel));

    // Create offset vector
    const offset = new BABYLON.Vector3(offsetX, offsetY, 0);

    // Apply same offset to both position and target to maintain view direction
    this.camera.position = baseCameraPosition.add(offset);
    this.camera.setTarget(center.add(offset));

    // Optional: zoom with mouse wheel
    this.scene.onPrePointerObservable.add((pointerInfo, eventState) => {
      if (pointerInfo.type === BABYLON.PointerEventTypes.POINTERWHEEL) {
        const delta = pointerInfo.event.deltaY * 0.01;
        const zoomDirection = this.camera.getTarget().subtract(this.camera.position).normalize();
        this.camera.position.addInPlace(zoomDirection.scale(delta));
        this.camera.setTarget(this.camera.getTarget()); // Ensure target remains updated
      }
    });
  }

  #pickCoordsFromCanvas(clientX, clientY) {
      const zOffset = -0.5; // Move point 0.5 units closer to camera

      // Try raycast into the scene
      const pickResult = this.scene.pick(clientX, clientY);
      if (pickResult.hit && pickResult.pickedPoint) {
          const p = pickResult.pickedPoint.clone();
          p.z += zOffset; // Bring it slightly toward the camera
          return p;
      }

      // Fallback: project onto a horizontal plane at avatar's head height, in front of camera
      const ray = this.camera.getForwardRay(10);
      const origin = ray.origin;
      const direction = ray.direction;

      // Project a point 3 units in front of the camera
      const fallbackPoint = origin.add(direction.scale(3));
      fallbackPoint.y = this.avatar.position.y + (this.eyeHeightOffset || 1.6);
      fallbackPoint.z += zOffset; // Also adjust fallback Z

      return fallbackPoint;
  }

  // Public Methods

  async lookAtCamera() {
    // recreate CameraObject
    this.#createPOIObject('CameraObject', this.camera.position.x, this.camera.position.y, this.camera.position.z);
    this.lookAt('CameraObject');
  }

  async lookAt(poiName) {
    try {
        // Find the mesh by name in the scene
        const object3d = this.scene.getMeshByName(poiName);
        if (object3d) {
            // Make the avatar look at the 3D object (mesh)
            this.host.PointOfInterestFeature.setTarget(object3d);
        }
    } catch (error) {
        console.log("avatar lookAt error", error);
    }
  }

  async lookAtCoords(x, y, z = null) {

      // Avatar look at Coords x,y and optionally z coords
      const poiName = 'ClickPOI';

      if(z === null){
        const poi = this.#pickCoordsFromCanvas(x, y);
        const z = 4;
        this.#createPOIObject(poiName, poi.x, poi.y, z); // look towards camera
      }else{
        this.#createPOIObject(poiName, x, y, z);
      }
      this.lookAt(poiName);
  }
  
  async load(ignoreAnimations = []){

    await this.#initServerConfig();

    // canvas setup
    const avatarContainer = document.getElementById('avatar-container');
    const canvas = document.createElement('canvas');
    canvas.id = 'renderCanvas';
    avatarContainer.appendChild(canvas);

    // scene setup
    this.engine = new BABYLON.Engine(canvas, true);
    this.scene = new BABYLON.Scene(this.engine);
    const {shadowGenerator} = this.#setupSceneEnvironment(this.scene); // setup scene, camera
    let light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(1, 1, 0), this.scene);

    // Create an invisible target object near the conversation widget
    this.#createPOIObject('ConversationPanelMiddle', -3, 1, -2);
    this.#createPOIObject('ConversationPanelFooter', -3, -1, -2);
    this.#createPOIObject('Flashcards', 3, 1, -2);
    // Create an invisible target object near the camera - this will be created on demand as it moves around
    //this.#createPOIObject('CameraObject', this.camera.position.x, this.camera.position.y, this.camera.position.z);

    // setup background
    if (this.backgroundUrl !== '') this.#createBackground();

    // setup AWS host
    console.log('creating Avatar..');

    // allow client to listen to loading progress
    const loadProgressListener = (progressEvent) => {
      if (progressEvent.lengthComputable) {
        let percent = Math.floor((progressEvent.loaded / progressEvent.total) * 100);
        const event = new CustomEvent('loadingProgress', {
          detail: { percent: percent, fileInfo: progressEvent.fileInfo }
        });
        this.eventTarget.dispatchEvent(event);
      }
    }    

    const pollyConfig = {pollyVoice: this.voiceName, pollyEngine: 'neural', pollyStreaming: this.streamingSpeech};
    const modelRootPath = `${twxChat.avatar_assets_path}glTF`;

    try{
      const characterConfig = HOST.HostObject.getCharacterConfig(modelRootPath, this.characterName, ignoreAnimations);
      this.host = await HOST.HostObject.createHost(this.scene, characterConfig, pollyConfig, loadProgressListener);
    }catch(error){
      console.log('error creating Avatar:',error);
      return {error:'There was an error loading the Avatar. Contact the Administrator.'};
    }

    const onStopSpeech = () => {
      this._isSpeaking = false;
      const event = new CustomEvent('speechComplete', {
          detail: { message: 'Speech has stopped successfully.' }
      });
      // emit event
      this.eventTarget.dispatchEvent(event);
    };
    HOST.aws.TextToSpeechFeature.listenTo(
        HOST.aws.TextToSpeechFeature.EVENTS.stop,
        onStopSpeech
    );

    const onPauseSpeech = () => {
      this._isSpeaking = false;
      //console.log('avatar speech paused');
    };
    HOST.aws.TextToSpeechFeature.listenTo(
        HOST.aws.TextToSpeechFeature.EVENTS.pause,
        onPauseSpeech
    );

    this.host.listenTo(
        this.host.TextToSpeechFeature.EVENTS['ssml'],
        (event) => {
          //console.log('[avatar] ssml reached: ',event);
          if(event.mark.value){
            const markJO = JSON.parse(event.mark.value);
            console.log('[avatar] mark reached: ',markJO);
            if(markJO.feature && markJO.feature === 'PointOfInterestFeature' 
              && markJO.method && markJO.method === 'setTargetByName' 
              && markJO.args){
                const poiName = markJO.args[0];
                if(poiName) this.lookAt(poiName);
              }
          }
        }
    );

    // Enable shadows
    this.scene.meshes.forEach(mesh => {
      shadowGenerator.addShadowCaster(mesh);
    });

    // animate
    this.scene.render();
    this.engine.runRenderLoop(() => this.scene.render());
    window.addEventListener('resize', () => {
        if (this.engine) {
            this.engine.resize();
        }
    });

    return {success:true};
  }

  resize(){
    this.engine.resize()
  }

  async speak(text, volume = 'x-loud') {

    let rate = this.voiceSpeed !== '' ? ' rate="'+this.voiceSpeed+'"' : '';
    let ssmlText = '<prosody volume="'+volume+'"'+rate+'>'+text+'</prosody>';

    try {
      this._isSpeaking = true;
      this.host.TextToSpeechFeature.play(ssmlText);
      //console.log(`have spoken SSML: ${ssmlText}`);

    }catch(error){
      console.log(`Error during host speak '${text}':`, error, ssmlText);
    }
  }

  async stopSpeech() {

    try {
        this.host.TextToSpeechFeature.stop();
    }catch(error){
        console.log('Error during host stopping speech:', error);
    }
  }

  async pauseSpeech() {

    try {
        this.host.TextToSpeechFeature.pause();
    }catch(error){
        console.log('Error during host pausing speech:', error);
    }
  }

  async resumeSpeech() {

      try {
          this._isSpeaking = true;
          this.host.TextToSpeechFeature.resume();
      }catch(error){
          console.log('Error during host resuming speech:', error);
      }
  }

  async lookThinking() {
    try {
      const poiName = 'ThinkingLook';
  
      let offsetX = 0.25;
      let offsetY = 0.25;
      let offsetZ = 0;

      this.#createPOIObject(
        poiName, 
        (this.camera.position.x + offsetX).toFixed(2), 
        (this.camera.position.y + offsetY).toFixed(2),
        (this.camera.position.z + offsetZ).toFixed(2)
      );

      this.lookAt(poiName);

    }catch(error){
      console.log('avatar lookThinking error',error);
    }
  }

  async zoomCamera(target, andLookAtCamera = false) {
  
    if(!this.zoomParams[target]) {
      console.log(`${target} not found in character data`);
      return;
    }
    
    const cdTarget = this.zoomParams[target];
    
    this.#zoomCameraToMesh(cdTarget.meshName, cdTarget.zoomLevel, cdTarget.offsetX, cdTarget.offsetY).then(() => {
      if(andLookAtCamera) this.lookAtCamera();
    });

  }

  async lookRandom() {
   
    try {
      const poiName = 'IdleLook';
  
      // Define a small offset range for random points around the camera
      let offsetX = (Math.random() - 0.5) * 1;  // Now between -0.5 and 0.5
      let offsetY = (Math.random() - 0.5) * 0.75; // Now between -0.375 and 0.375
      let offsetZ = (Math.random() - 0.5) * 1;  // Now between -0.5 and 0.5

      this.#createPOIObject(
        poiName, 
        (this.camera.position.x + offsetX).toFixed(2), 
        (this.camera.position.y + offsetY).toFixed(2),
        (this.camera.position.z + offsetZ).toFixed(2)
      );

      this.lookAt(poiName);

    }catch(error){
      console.log('avatar lookRandom error',error);
    }
  }

  isSpeaking(){
    return this._isSpeaking;
  }

  async exit() {

    await this.stopSpeech();

    if (this.engine) {
      this.engine.stopRenderLoop();  // Stop rendering
    }
  
    if (this.scene) {
      this.scene.dispose();  // Dispose scene and meshes
      this.scene = null;
    }
  
    if (this.engine) {
      this.engine.dispose();  // Dispose Babylon engine
      this.engine = null;
    }
  
    // Remove canvas from DOM if needed
    //const avatarContainer = document.getElementById('avatar-container');
    //const canvas = document.getElementById('canvas');
    const canvas = document.getElementById('renderCanvas');
    if (canvas && canvas.parentNode) {
      canvas.parentNode.removeChild(canvas);
    }
  
    console.log('avatar and rendering stopped.');    
  }

  onSpeechComplete(callback) { 
    this.eventTarget.addEventListener('speechComplete', callback);
  }    

  onLoadingProgress(callback) { 
    this.eventTarget.addEventListener('loadingProgress', callback);
  }    


}

window.TWXChatAvatar = TWXChatAvatar;

