/* @license
 * Copyright 2019 Google LLC. All Rights Reserved.
 * Licensed under the Apache License, Version 2.0 (the 'License');
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an 'AS IS' BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {Box3, BufferGeometry, Camera, Event as ThreeEvent, EventDispatcher, Line, Matrix4, Object3D, PerspectiveCamera, Quaternion, Vector3, WebGLRenderer, XRControllerEventType, XRTargetRaySpace} from 'three';
import {XREstimatedLight} from 'three/examples/jsm/webxr/XREstimatedLight.js';

import {CameraChangeDetails, ControlsInterface} from '../features/controls.js';
import {$currentBackground, $currentEnvironmentMap} from '../features/environment.js';
import ModelViewerElementBase from '../model-viewer-base.js';
import {assertIsArCandidate} from '../utilities.js';

import {Damper} from './Damper.js';
import {ModelScene} from './ModelScene.js';
import {PlacementBox} from './PlacementBox.js';
import {Renderer} from './Renderer.js';
import {ChangeSource} from './SmoothControls.js';
import {XRMenuPanel} from './XRMenuPanel.js';

// number of initial null pose XRFrames allowed before we post not-tracking
const INIT_FRAMES = 30;
// AR shadow is not user-configurable. This is to pave the way for AR lighting
// estimation, which will be used once available in WebXR.
const AR_SHADOW_INTENSITY = 0.8;
const ROTATION_RATE = 1.5;
// Angle down (towards bottom of screen) from camera center ray to use for hit
// testing against the floor. This makes placement faster and more intuitive
// assuming the phone is in portrait mode. This seems to be a reasonable
// assumption for the start of the session and UI will lack landscape mode to
// encourage upright use.
const HIT_ANGLE_DEG = 20;
const SCALE_SNAP = 0.2;
// For automatic dynamic viewport scaling, don't let the scale drop below this
// limit.
const MIN_VIEWPORT_SCALE = 0.25;
// Furthest away you can move an object (meters).
const MAX_DISTANCE = 10;
// Damper decay in milliseconds for the headset - screen uses default.
const DECAY = 150;
// Longer controller/hand indicator line (meters).
const MAX_LINE_LENGTH = 5;
// Axis Y in webxr.
const AXIS_Y = new Vector3(0, 1, 0);
// Webxr rotation sensitivity
const ROTATION_SENSIVITY = 0.3;

// World-space AR automatic positioning constants (similar to FrameModel
// approach in SVXR)
const MIN_WORLD_SPACE_DISTANCE = 1.5;  // Minimum distance from camera (meters)
const VIEW_DROP_DEGREES =
    15;                  // Angle down from camera center for optimal viewing
const VIEW_RATIO = 0.3;  // Ratio of viewport to use for model sizing
const MIN_MODEL_SIZE = 0.01;  // Minimum model size to prevent division by zero
const PLACEMENT_BOX_EXTRA_PADDING = 0.15;  // Extra padding for model-viewer

// SVXR-like constants for scale limits
const MAX_MODEL_SIZE = 70.0;
const MODEL_SIZE_EPSILON = 0.001;

const FOOTPRINT__INTERSECT_THRESHOLD = 0.2;

export type ARStatus =
    'not-presenting'|'session-started'|'object-placed'|'failed';

export const ARStatus: {[index: string]: ARStatus} = {
  NOT_PRESENTING: 'not-presenting',
  SESSION_STARTED: 'session-started',
  OBJECT_PLACED: 'object-placed',
  FAILED: 'failed'
};

export interface ARStatusEvent extends ThreeEvent {
  status: ARStatus,
}

export type ARTracking = 'tracking'|'not-tracking';

export const ARTracking: {[index: string]: ARTracking} = {
  TRACKING: 'tracking',
  NOT_TRACKING: 'not-tracking'
};

export interface ARTrackingEvent extends ThreeEvent {
  status: ARTracking,
}

interface UserData {
  turning: boolean
  line: Line
  isSelected: boolean
  initialX: number
}

interface XRController extends XRTargetRaySpace {
  userData: UserData
}

interface XRControllerEvent {
  type: XRControllerEventType, data: XRInputSource, target: XRController
}

const vector3 = new Vector3();
const quaternion = new Quaternion();
const matrix4 = new Matrix4();
const hitPosition = new Vector3();
const camera = new PerspectiveCamera(45, 1, 0.1, 100);
const lineGeometry = new BufferGeometry().setFromPoints(
    [new Vector3(0, 0, 0), new Vector3(0, 0, -1)]);

export const XRMode = {
  SCREEN_SPACE: 'screen-space',
  WORLD_SPACE: 'world-space'
} as const;
export type XRMode = typeof XRMode[keyof typeof XRMode];

export class ARRenderer extends EventDispatcher<
    {status: {status: ARStatus}, tracking: {status: ARTracking}}> {
  public threeRenderer: WebGLRenderer;
  public currentSession: XRSession|null = null;
  public placeOnWall = false;

  private placementBox: PlacementBox|null = null;
  private menuPanel: XRMenuPanel|null = null;
  private lastTick: number|null = null;
  private turntableRotation: number|null = null;
  private oldShadowIntensity: number|null = null;
  private frame: XRFrame|null = null;
  private initialHitSource: XRHitTestSource|null = null;
  private transientHitTestSource: XRTransientInputHitTestSource|null = null;
  private inputSource: XRInputSource|null = null;
  private _presentedScene: ModelScene|null = null;
  private resolveCleanup: ((...args: any[]) => void)|null = null;
  private exitWebXRButtonContainer: HTMLElement|null = null;
  private overlay: HTMLElement|null = null;
  private xrLight: XREstimatedLight|null = null;
  private xrMode: XRMode|null = null;
  private xrController1: XRController|null = null;
  private xrController2: XRController|null = null;
  private selectedXRController: XRController|null = null;

  private tracking = true;
  private frames = 0;
  private initialized = false;
  private oldTarget = new Vector3();
  private placementComplete = false;
  private isTranslating = false;
  private isRotating = false;
  private isTwoHandInteraction = false;
  private lastDragPosition = new Vector3();
  private deltaRotation = new Quaternion();
  private scaleLine = new Line(lineGeometry);
  private firstRatio = 0;
  private lastAngle = 0;
  private goalPosition = new Vector3();
  private goalYaw = 0;
  private goalScale = 1;
  private xDamper = new Damper();
  private yDamper = new Damper();
  private zDamper = new Damper();
  private yawDamper = new Damper();
  private pitchDamper = new Damper();
  private rollDamper = new Damper();
  private scaleDamper = new Damper();
  private wasTwoFingering = false;

  // Track if initial automatic placement has been done for world-space mode
  private worldSpaceInitialPlacementDone = false;

  // Scale toggle state for world-space mode
  private initialModelScale = 1.0;
  private minScale = 1.0;
  private maxScale = 1.0;

  private onExitWebXRButtonContainerClick = () => this.stopPresenting();

  /**
   * Check if world-space mode is active and initial placement is complete
   */
  private isWorldSpaceReady(): boolean {
    return this.xrMode === XRMode.WORLD_SPACE &&
        this.worldSpaceInitialPlacementDone;
  }

  constructor(private renderer: Renderer) {
    super();
    this.threeRenderer = renderer.threeRenderer;
    this.threeRenderer.xr.enabled = true;
  }

  async resolveARSession(): Promise<XRSession> {
    assertIsArCandidate();

    const session: XRSession =
        await navigator.xr!.requestSession!('immersive-ar', {
          requiredFeatures: [],
          optionalFeatures: ['hit-test', 'dom-overlay', 'light-estimation'],
          domOverlay: this.overlay ? {root: this.overlay} : undefined
        });

    this.threeRenderer.xr.setReferenceSpaceType('local');

    await this.threeRenderer.xr.setSession(session);

    this.threeRenderer.xr.cameraAutoUpdate = false;

    return session;
  }

  /**
   * The currently presented scene, if any
   */
  get presentedScene() {
    return this._presentedScene;
  }

  /**
   * Resolves to true if the renderer has detected all the necessary qualities
   * to support presentation in AR.
   */
  async supportsPresentation(): Promise<boolean> {
    try {
      assertIsArCandidate();
      return await navigator.xr!.isSessionSupported('immersive-ar');
    } catch (error) {
      console.warn('Request to present in WebXR denied:');
      console.warn(error);
      console.warn('Falling back to next ar-mode');
      return false;
    }
  }

  /**
   * Present a scene in AR
   */
  async present(scene: ModelScene, environmentEstimation: boolean = false):
      Promise<void> {
    if (this.isPresenting) {
      console.warn('Cannot present while a model is already presenting');
    }

    let waitForAnimationFrame = new Promise<void>((resolve, _reject) => {
      requestAnimationFrame(() => resolve());
    });

    scene.setHotspotsVisibility(false);
    scene.queueRender();
    // Render a frame to turn off the hotspots
    await waitForAnimationFrame;

    // This sets isPresenting to true
    this._presentedScene = scene;
    this.overlay = scene.element.shadowRoot!.querySelector('div.default');
    if (this.overlay != null) {
      this.overlay.addEventListener('beforexrselect', this.onBeforeXRSelect);
    }

    if (environmentEstimation === true) {
      this.xrLight = new XREstimatedLight(this.threeRenderer);

      this.xrLight.addEventListener('estimationstart', () => {
        if (!this.isPresenting || this.xrLight == null) {
          return;
        }

        const scene = this.presentedScene!;
        scene.add(this.xrLight);

        scene.environment = this.xrLight.environment;
      });
    }

    const currentSession = await this.resolveARSession();

    currentSession.addEventListener('end', () => {
      this.postSessionCleanup();
    }, {once: true});

    const exitButton = scene.element.shadowRoot!.querySelector(
                           '.slot.exit-webxr-ar-button') as HTMLElement;
    exitButton.classList.add('enabled');
    exitButton.addEventListener('click', this.onExitWebXRButtonContainerClick);
    this.exitWebXRButtonContainer = exitButton;

    const viewerRefSpace = await currentSession.requestReferenceSpace('viewer');

    this.xrMode = (currentSession as any).interactionMode;

    this.tracking = true;
    this.frames = 0;
    this.initialized = false;

    this.turntableRotation = scene.yaw;
    this.goalYaw = scene.yaw;
    this.goalScale = 1;

    scene.setBackground(null);

    this.oldShadowIntensity = scene.shadowIntensity;
    scene.setShadowIntensity(0.01);  // invisible, but not changing the shader

    this.oldTarget.copy(scene.getTarget());

    scene.element.addEventListener('load', this.onUpdateScene);

    // Create hit test source for all modes (will be used differently based on
    // mode and state)
    const radians = HIT_ANGLE_DEG * Math.PI / 180;
    const ray = this.placeOnWall === true ?
        undefined :
        new XRRay(
            new DOMPoint(0, 0, 0),
            {x: 0, y: -Math.sin(radians), z: -Math.cos(radians)});
    currentSession
        .requestHitTestSource!
        ({space: viewerRefSpace, offsetRay: ray})!.then(hitTestSource => {
          this.initialHitSource = hitTestSource;
        });

    if (this.xrMode !== XRMode.SCREEN_SPACE) {
      this.setupXRControllers();
      this.xDamper.setDecayTime(DECAY);
      this.yDamper.setDecayTime(DECAY);
      this.zDamper.setDecayTime(DECAY);
      this.yawDamper.setDecayTime(DECAY);
      this.pitchDamper.setDecayTime(DECAY);
      this.rollDamper.setDecayTime(DECAY);
      this.scaleDamper.setDecayTime(DECAY);
    }

    this.currentSession = currentSession;
    this.placementBox =
        new PlacementBox(scene, this.placeOnWall ? 'back' : 'bottom');

    // Set screen space mode for proper positioning
    if (this.placementBox) {
      this.placementBox.setScreenSpaceMode(this.xrMode === XRMode.SCREEN_SPACE);
    }

    this.placementComplete = false;

    if (this.xrMode !== XRMode.SCREEN_SPACE) {
      this.menuPanel = new XRMenuPanel();
      scene.add(this.menuPanel);
      this.menuPanel.updatePosition(
          scene.getCamera(), this.placementBox!);  // Position the menu panel
    }

    this.lastTick = performance.now();
    this.dispatchEvent({type: 'status', status: ARStatus.SESSION_STARTED});
  }

  private setupXRControllerLine(xrController: XRController) {
    if (!xrController.userData.line) {
      const line = new Line(lineGeometry);
      line.name = 'line';
      line.scale.z = MAX_LINE_LENGTH;
      xrController.userData.turning = false;
      xrController.userData.line = line;
      xrController.add(line);
    }
  }

  private setupController(controller: XRController) {
    this.setupXRControllerLine(controller);
    controller.addEventListener('selectstart', this.onControllerSelectStart);
    controller.addEventListener('selectend', this.onControllerSelectEnd);
  }
  private setupXRControllers() {
    this.xrController1 = this.threeRenderer.xr.getController(0) as XRController;
    this.xrController2 = this.threeRenderer.xr.getController(1) as XRController;

    this.setupController(this.xrController1);
    this.setupController(this.xrController2);

    this.scaleLine.name = 'scale line';
    this.scaleLine.visible = false;
    this.xrController1.add(this.scaleLine);

    // Add controllers to the scene
    const scene = this.presentedScene!;
    scene.add(this.xrController1);
    scene.add(this.xrController2);
  }

  private hover(xrController: XRTargetRaySpace): boolean {
    // Do not highlight in mobile-ar
    if (this.xrMode === XRMode.SCREEN_SPACE ||
        this.selectedXRController == xrController) {
      return false;
    }

    const scene = this.presentedScene!;
    const intersection =
        this.placementBox!.controllerIntersection(scene, xrController);
    xrController.userData.line.scale.z =
        intersection == null ? MAX_LINE_LENGTH : intersection.distance;
    return intersection != null;
  }

  private controllerSeparation() {
    return this.xrController1!.position.distanceTo(
        this.xrController2!.position);
  }

  private onControllerSelectStart = (event: XRControllerEvent) => {
    const scene = this.presentedScene!;
    const controller = event.target;
    const menuPanel = this.menuPanel;

    const exitIntersect =
        this.menuPanel!.exitButtonControllerIntersection(scene, controller);
    if (exitIntersect != null) {
      this.menuPanel?.dispose();
      this.stopPresenting();
      return;
    }

    const scaleModeIntersect =
        this.menuPanel!.scaleModeButtonControllerIntersection(
            scene, controller);
    if (scaleModeIntersect != null) {
      const goalScale = this.menuPanel!.handleScaleToggle(
          this.worldSpaceInitialPlacementDone,
          this.initialModelScale,
          this.minScale,
          this.maxScale);
      if (goalScale !== null) {
        this.goalScale = goalScale;
      }
      return;
    }

    if (menuPanel) {
      menuPanel!.show = false;
    }

    const intersection =
        this.placementBox!.controllerIntersection(scene, controller);
    if (intersection != null) {
      const bbox = new Box3().setFromObject(scene.scenePivot);
      const footprintY = bbox.min.y +
          FOOTPRINT__INTERSECT_THRESHOLD;  // Small threshold above base

      // Check if the ray intersection is near the footprint
      const isFootprint = intersection.point.y <= footprintY;
      if (isFootprint) {
        if (this.selectedXRController != null) {
          this.selectedXRController.userData.line.visible = false;
          if (scene.canScale && this.isWorldSpaceReady()) {
            this.isTwoHandInteraction = true;
            this.firstRatio = this.controllerSeparation() / scene.scenePivot.scale.x;
            this.scaleLine.visible = true;
          }
        } else {
          controller.attach(scene.scenePivot);
        }
        this.selectedXRController = controller;
        scene.setShadowIntensity(0.01);
      } else {
        if (controller == this.xrController1) {
          this.xrController1.userData.isSelected = true;
        } else if (controller == this.xrController2) {
          this.xrController2.userData.isSelected = true;
        }

        if (this.xrController1?.userData.isSelected &&
            this.xrController2?.userData.isSelected) {
          if (scene.canScale && this.isWorldSpaceReady()) {
            this.isTwoHandInteraction = true;
            this.firstRatio = this.controllerSeparation() / scene.scenePivot.scale.x;
            this.scaleLine.visible = true;
          }
        } else {
          const otherController = controller === this.xrController1 ?
              this.xrController2! :
              this.xrController1!;
          controller.userData.initialX = controller.position.x;
          otherController.userData.turning = false;
          controller.userData.turning = true;
          controller.userData.line.visible = false;
        }
      }
    }
  };

  private onControllerSelectEnd = (event: XRControllerEvent) => {
    const controller = event.target;
    controller.userData.turning = false;
    controller.userData.line.visible = true;
    this.isTwoHandInteraction = false;
    this.scaleLine.visible = false;

    if (controller == this.xrController1) {
      this.xrController1.userData.isSelected = false;
    } else if (controller == this.xrController2) {
      this.xrController2.userData.isSelected = false;
    }

    if (this.selectedXRController != null &&
        this.selectedXRController != controller) {
      return;
    }
    const scene = this.presentedScene!;
    // drop on floor
    scene.attach(scene.scenePivot);
    this.selectedXRController = null;
    this.goalYaw = Math.atan2(
        scene.scenePivot.matrix.elements[8], scene.scenePivot.matrix.elements[10]);
    this.goalPosition.x = scene.scenePivot.position.x;
    this.goalPosition.z = scene.scenePivot.position.z;

    // For world-space mode after initial placement, preserve Y position
    if (this.isWorldSpaceReady()) {
      this.goalPosition.y = scene.scenePivot.position.y;
    }

    const menuPanel = this.menuPanel;
    menuPanel!.show = true;
  };

  /**
   * If currently presenting a scene in AR, stops presentation and exits AR.
   */
  async stopPresenting() {
    if (!this.isPresenting) {
      return;
    }

    const cleanupPromise = new Promise((resolve) => {
      this.resolveCleanup = resolve;
    });

    try {
      await this.currentSession!.end();
      await cleanupPromise;
    } catch (error) {
      console.warn('Error while trying to end WebXR AR session');
      console.warn(error);

      this.postSessionCleanup();
    }
  }

  /**
   * True if a scene is currently in the process of being presented in AR
   */
  get isPresenting(): boolean {
    return this.presentedScene != null;
  }

  get target(): Vector3 {
    return this.oldTarget;
  }

  updateTarget() {
    const scene = this.presentedScene;
    if (scene != null) {
      const target = scene.getTarget();
      this.oldTarget.copy(target);
      if (this.placeOnWall) {
        // Move the scene's target to the center of the back of the model's
        // bounding box.
        target.z = scene.boundingBox.min.z;
      } else {
        // Move the scene's target to the model's floor height.
        target.y = scene.boundingBox.min.y;
      }
      scene.setTarget(target.x, target.y, target.z);
    }
  }

  onUpdateScene = () => {
    if (this.placementBox != null && this.isPresenting) {
      // Update the existing placement box with new model dimensions instead of
      // recreating
      this.placementBox!.updateFromModelChanges();

      // Ensure screen space mode is maintained
      this.placementBox!.setScreenSpaceMode(
          this.xrMode === XRMode.SCREEN_SPACE);
    }
    if (this.xrMode !== XRMode.SCREEN_SPACE) {
      if (this.menuPanel) {
        this.menuPanel.dispose();
        this.menuPanel = null;
      }
      this.menuPanel = new XRMenuPanel();
      this.presentedScene!.add(this.menuPanel);
      this.menuPanel.updatePosition(
          this.presentedScene!.getCamera(), this.placementBox!);
    }
  };

  private cleanupXRController(xrController: XRController) {
    xrController.userData.turning = false;
    xrController.userData.line.visible = true;
    xrController.removeEventListener(
        'selectstart', this.onControllerSelectStart);
    xrController.removeEventListener('selectend', this.onControllerSelectEnd);
    xrController.removeFromParent();
  }

  private postSessionCleanup() {
    const session = this.currentSession;
    if (session != null) {
      session.removeEventListener('selectstart', this.onSelectStart);
      session.removeEventListener('selectend', this.onSelectEnd);
      this.currentSession = null;
    }

    const scene = this.presentedScene;
    this._presentedScene = null;
    if (scene != null) {
      const {element} = scene;

      if (this.xrLight != null) {
        scene.remove(this.xrLight);
        (this.xrLight as any).dispose();
        this.xrLight = null;
      }

      if (this.menuPanel != null) {
        this.menuPanel.dispose();
        this.menuPanel = null;
      }

      scene.add(scene.scenePivot);
      scene.scenePivot.quaternion.set(0, 0, 0, 1);
      scene.scenePivot.position.set(0, 0, 0);
      scene.scenePivot.scale.set(1, 1, 1);
      scene.setShadowOffset(0);
      const yaw = this.turntableRotation;
      if (yaw != null) {
        scene.yaw = yaw;
      }
      const intensity = this.oldShadowIntensity;
      if (intensity != null) {
        scene.setShadowIntensity(intensity);
      }
      scene.setEnvironmentAndSkybox(
          (element as any)[$currentEnvironmentMap],
          (element as any)[$currentBackground]);
      const point = this.oldTarget;
      scene.setTarget(point.x, point.y, point.z);
      scene.xrCamera = null;

      scene.element.removeEventListener('load', this.onUpdateScene);
      scene.orientHotspots(0);
      const {width, height} = element.getBoundingClientRect();
      scene.setSize(width, height);

      requestAnimationFrame(() => {
        scene.element.dispatchEvent(new CustomEvent<CameraChangeDetails>(
            'camera-change', {detail: {source: ChangeSource.NONE}}));
      });
    }

    // Force the Renderer to update its size
    this.renderer.height = 0;

    const exitButton = this.exitWebXRButtonContainer;
    if (exitButton != null) {
      exitButton.classList.remove('enabled');
      exitButton.removeEventListener(
          'click', this.onExitWebXRButtonContainerClick);
      this.exitWebXRButtonContainer = null;
    }

    const hitSource = this.transientHitTestSource;
    if (hitSource != null) {
      hitSource.cancel();
      this.transientHitTestSource = null;
    }

    const hitSourceInitial = this.initialHitSource;
    if (hitSourceInitial != null) {
      hitSourceInitial.cancel();
      this.initialHitSource = null;
    }

    if (this.placementBox != null) {
      this.placementBox!.dispose();
      this.placementBox = null;
    }

    if (this.xrMode !== XRMode.SCREEN_SPACE) {
      if (this.xrController1 != null) {
        this.cleanupXRController(this.xrController1);
        this.xrController1 = null;
      }
      if (this.xrController2 != null) {
        this.cleanupXRController(this.xrController2);
        this.xrController2 = null;
      }
      this.selectedXRController = null;
      this.scaleLine.visible = false;
    }

    this.isTranslating = false;
    this.isRotating = false;
    this.isTwoHandInteraction = false;
    this.lastTick = null;
    this.turntableRotation = null;
    this.oldShadowIntensity = null;
    this.frame = null;
    this.inputSource = null;
    if (this.overlay != null) {
      this.overlay.removeEventListener('beforexrselect', this.onBeforeXRSelect);
      this.overlay = null;
    }
    this.worldSpaceInitialPlacementDone = false;

    if (this.resolveCleanup != null) {
      this.resolveCleanup!();
    }

    this.dispatchEvent({type: 'status', status: ARStatus.NOT_PRESENTING});
  }

  private updateView(view: XRView) {
    const scene = this.presentedScene!;
    const xr = this.threeRenderer.xr;

    xr.updateCamera(camera);
    scene.xrCamera = xr.getCamera();
    const {elements} = scene.getCamera().matrixWorld;
    scene.orientHotspots(Math.atan2(elements[1], elements[5]));

    if (!this.initialized) {
      this.placeInitially();
      this.initialized = true;
    }

    // Use automatic dynamic viewport scaling if supported.
    if (view.requestViewportScale && view.recommendedViewportScale) {
      const scale = view.recommendedViewportScale;
      view.requestViewportScale(Math.max(scale, MIN_VIEWPORT_SCALE));
    }
    const layer = xr.getBaseLayer();
    if (layer != null) {
      const viewport = layer instanceof XRWebGLLayer ?
          layer!.getViewport(view)! :
          xr.getBinding().getViewSubImage(layer, view).viewport;
      this.threeRenderer.setViewport(
          viewport.x, viewport.y, viewport.width, viewport.height);
    }
  }

  private placeInitially() {
    const scene = this.presentedScene!;
    const {scenePivot, element} = scene;
    const {position} = scenePivot;
    const xrCamera = scene.getCamera();

    const {width, height} = this.overlay!.getBoundingClientRect();
    scene.setSize(width, height);

    xrCamera.projectionMatrixInverse.copy(xrCamera.projectionMatrix).invert();

    const {theta} = (element as ModelViewerElementBase & ControlsInterface)
                        .getCameraOrbit();

    // Orient model to match the 3D camera view
    const cameraDirection = xrCamera.getWorldDirection(vector3);
    scene.yaw = Math.atan2(-cameraDirection.x, -cameraDirection.z) - theta;
    this.goalYaw = scene.yaw;

    // Use different placement logic for world-space vs screen-space
    if (this.xrMode === XRMode.WORLD_SPACE &&
        !this.worldSpaceInitialPlacementDone) {
      // Use automatic optimal placement for world-space AR only on first
      // session
      const {position: optimalPosition, scale: optimalScale} =
          this.calculateWorldSpaceOptimalPlacement(scene, xrCamera);

      this.goalPosition.copy(optimalPosition);
      this.goalScale = optimalScale;

      // Store the initial scale for toggle functionality
      this.initialModelScale = optimalScale;

      // Set initial position and scale immediately for world-space
      position.copy(optimalPosition);
      scenePivot.scale.set(optimalScale, optimalScale, optimalScale);

      // Mark that initial placement is done
      this.worldSpaceInitialPlacementDone = true;

      // Calculate scale limits for world-space mode (SVXR logic)
      this.calculateWorldSpaceScaleLimits(scene);

      // Enable user interaction after initial placement
      this.enableWorldSpaceUserInteraction();
    } else if (this.xrMode === XRMode.SCREEN_SPACE) {
      // Use original placement logic for screen-space AR
      const radius = Math.max(1, 2 * scene.boundingSphere.radius);
      position.copy(xrCamera.position)
          .add(cameraDirection.multiplyScalar(radius));

      this.updateTarget();
      const target = scene.getTarget();
      position.add(target).sub(this.oldTarget);

      this.goalPosition.copy(position);
    }

    scene.setHotspotsVisibility(true);

    if (this.xrMode === XRMode.SCREEN_SPACE) {
      const {session} = this.frame!;
      session.addEventListener('selectstart', this.onSelectStart);
      session.addEventListener('selectend', this.onSelectEnd);
      session
          .requestHitTestSourceForTransientInput!
          ({profile: 'generic-touchscreen'})!.then(hitTestSource => {
            this.transientHitTestSource = hitTestSource;
          });
    }
  }

  private getTouchLocation(): Vector3|null {
    const {axes} = this.inputSource!.gamepad!;
    let location = this.placementBox!.getExpandedHit(
        this.presentedScene!, axes[0], axes[1]);
    if (location != null) {
      vector3.copy(location).sub(this.presentedScene!.getCamera().position);
      if (vector3.length() > MAX_DISTANCE)
        return null;
    }
    return location;
  }

  private getHitPoint(hitResult: XRHitTestResult): Vector3|null {
    const refSpace = this.threeRenderer.xr.getReferenceSpace()!;
    const pose = hitResult.getPose(refSpace);
    if (pose == null) {
      return null;
    }

    const hitMatrix = matrix4.fromArray(pose.transform.matrix);

    if (this.placeOnWall === true) {
      // Orient the model to the wall's normal vector.
      this.goalYaw = Math.atan2(hitMatrix.elements[4], hitMatrix.elements[6]);
    }
    // Check that the y-coordinate of the normal is large enough that the normal
    // is pointing up for floor placement; opposite for wall placement.
    return hitMatrix.elements[5] > 0.75 !== this.placeOnWall ?
        hitPosition.setFromMatrixPosition(hitMatrix) :
        null;
  }

  public moveToFloor(frame: XRFrame) {
    // Skip hit testing for world-space mode only during initial placement
    if (this.xrMode === XRMode.WORLD_SPACE &&
        !this.worldSpaceInitialPlacementDone) {
      this.placementBox!.show = false;
      this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});
      return;
    }

    const hitSource = this.initialHitSource;
    if (hitSource == null) {
      return;
    }

    const hitTestResults = frame.getHitTestResults(hitSource);
    if (hitTestResults.length == 0) {
      return;
    }

    const hit = hitTestResults[0];
    const hitPoint = this.getHitPoint(hit);
    if (hitPoint == null) {
      return;
    }

    this.placementBox!.show = true;

    // If the user is translating, let the finger hit-ray take precedence and
    // ignore this hit result.
    if (!this.isTranslating) {
      if (this.placeOnWall) {
        this.goalPosition.copy(hitPoint);
      } else {
        this.goalPosition.y = hitPoint.y;
      }
    }

    hitSource.cancel();
    this.initialHitSource = null;
    this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});
  }

  private onSelectStart = (event: Event) => {
    const hitSource = this.transientHitTestSource;
    if (hitSource == null) {
      return;
    }
    const fingers = this.frame!.getHitTestResultsForTransientInput(hitSource);
    const scene = this.presentedScene!;
    const box = this.placementBox!;

    if (fingers.length === 1) {
      this.inputSource = (event as XRInputSourceEvent).inputSource;
      const {axes} = this.inputSource!.gamepad!;

      const hitPosition = box.getHit(this.presentedScene!, axes[0], axes[1]);
      box.show = true;

      if (hitPosition != null) {
        this.isTranslating = true;
        this.lastDragPosition.copy(hitPosition);
      } else if (this.placeOnWall === false) {
        this.isRotating = true;
        this.lastAngle = axes[0] * ROTATION_RATE;
      }
    } else if (fingers.length === 2) {
      box.show = true;
      this.isTwoHandInteraction = true;
      const {separation, angle} = this.fingerPolar(fingers);
      this.lastAngle = angle;  // Initialize lastAngle, do not update goalYaw
      if (this.firstRatio === 0) {
        this.firstRatio = separation / scene.scenePivot.scale.x;
      }
      if (scene.canScale) {
        this.setScale(separation);
      }
      return;
    }
  };

  private onSelectEnd = () => {
    this.isTranslating = false;
    this.isRotating = false;
    this.isTwoHandInteraction = false;
    this.inputSource = null;
    this.goalPosition.y +=
        this.placementBox!.offsetHeight * this.presentedScene!.scale.x;
    this.placementBox!.show = false
  };

  private onBeforeXRSelect = (event: Event) => {
    const path = event.composedPath();
    for (const element of path) {
      if (element instanceof HTMLElement) {
        const tagName = element.tagName.toLowerCase();
        if (tagName === 'input' || tagName === 'button' ||
            tagName === 'select' || tagName === 'textarea' ||
            tagName === 'a' || element.hasAttribute('data-pointer-coalesce') ||
            element.classList.contains('interactive')) {
          event.preventDefault();
          break;
        }
      }
    }
  };

  private fingerPolar(fingers: XRTransientInputHitTestResult[]):
      {separation: number, deltaYaw: number, angle: number} {
    const fingerOne = fingers[0].inputSource.gamepad!.axes;
    const fingerTwo = fingers[1].inputSource.gamepad!.axes;
    const deltaX = fingerTwo[0] - fingerOne[0];
    const deltaY = fingerTwo[1] - fingerOne[1];
    const angle = Math.atan2(deltaY, deltaX);
    let deltaYaw = this.lastAngle - angle;
    if (deltaYaw > Math.PI) {
      deltaYaw -= 2 * Math.PI;
    } else if (deltaYaw < -Math.PI) {
      deltaYaw += 2 * Math.PI;
    }
    return {
      separation: Math.sqrt(deltaX * deltaX + deltaY * deltaY),
      deltaYaw: deltaYaw,
      angle: angle
    };
  }

  private setScale(separation: number) {
    let scale = separation / this.firstRatio;
    scale = (Math.abs(scale - 1) < SCALE_SNAP) ? 1 : scale;
    // Clamp to min/max for world-space mode after initial placement
    if (this.isWorldSpaceReady()) {
      scale = Math.max(this.minScale, Math.min(this.maxScale, scale));
    }
    this.goalScale = scale;
  }

  private processInput(frame: XRFrame) {
    const hitSource = this.transientHitTestSource;
    if (hitSource == null) {
      return;
    }
    const fingers = frame.getHitTestResultsForTransientInput(hitSource);
    const scene = this.presentedScene!;
    const scale = scene.scenePivot.scale.x;

    // Robust two-finger gesture handling
    if (fingers.length === 2) {
      if (!this.wasTwoFingering) {
        // New two-finger gesture starting
        const {separation, angle} = this.fingerPolar(fingers);
        this.firstRatio = separation / scale;
        this.lastAngle = angle;
        this.wasTwoFingering = true;
        this.isTwoHandInteraction = true;
        // Do not apply rotation or scale on this frame
        return;
      }
      // Ongoing two-finger gesture
      const {separation, deltaYaw, angle} = this.fingerPolar(fingers);
      this.goalYaw += deltaYaw;
      this.lastAngle = angle;
      if (scene.canScale) {
        this.setScale(separation);
      }
      this.isTwoHandInteraction = true;
      return;
    } else {
      if (this.wasTwoFingering) {
        // Two-finger gesture ended
        this.wasTwoFingering = false;
        this.isTwoHandInteraction = false;
        this.firstRatio = 0;
      }
    }

    if (!this.isTranslating && !this.isTwoHandInteraction && !this.isRotating) {
      return;
    }

    // Handle single finger interactions
    if (this.isRotating) {
      const angle = this.inputSource!.gamepad!.axes[0] * ROTATION_RATE;
      this.goalYaw += angle - this.lastAngle;
      this.lastAngle = angle;
    } else if (this.isTranslating) {
      fingers.forEach(finger => {
        if (finger.inputSource !== this.inputSource) {
          return;
        }

        let hit = null;
        if (finger.results.length > 0) {
          hit = this.getHitPoint(finger.results[0]);
        }
        if (hit == null) {
          hit = this.getTouchLocation();
        }
        if (hit == null) {
          return;
        }

        this.goalPosition.sub(this.lastDragPosition);

        // For world-space mode after initial placement, allow full Y-axis
        // control
        if (this.isWorldSpaceReady()) {
          // Use the hit point directly without floor constraints
          console.log('[processInput] Setting goalPosition.y to hit.y:', hit.y);
          this.goalPosition.add(hit);
        } else if (this.placeOnWall === false) {
          // Original logic for screen-space or initial world-space placement
          const offset = hit.y - this.lastDragPosition.y;
          // When a lower floor is found, keep the model at the same height, but
          // drop the placement box to the floor. The model falls on select end.
          if (offset < 0) {
            this.placementBox!.offsetHeight = offset / scale;
            this.presentedScene!.setShadowOffset(offset);
            // Interpolate hit ray up to drag plane
            const cameraPosition = vector3.copy(scene.getCamera().position);
            const alpha = -offset / (cameraPosition.y - hit.y);
            cameraPosition.multiplyScalar(alpha);
            hit.multiplyScalar(1 - alpha).add(cameraPosition);
          }
          this.goalPosition.add(hit);
        } else {
          this.goalPosition.add(hit);
        }

        this.lastDragPosition.copy(hit);
      });
    }
  }

  private applyXRControllerRotation(controller: XRController, scenePivot: Object3D) {
    if (!controller.userData.turning) {
      return;
    }
    const angle = (controller.position.x - controller.userData.initialX) *
        ROTATION_SENSIVITY;
    this.deltaRotation.setFromAxisAngle(AXIS_Y, angle);
    scenePivot.quaternion.multiplyQuaternions(this.deltaRotation, scenePivot.quaternion);
  }

  private handleScalingInXR(scene: ModelScene, delta: number) {
    // Allow manual scaling for world-space mode after initial placement
    if (this.xrMode === XRMode.WORLD_SPACE &&
        !this.worldSpaceInitialPlacementDone) {
      return;
    }

    if (this.xrController1 && this.xrController2 && this.isTwoHandInteraction) {
      const dist = this.controllerSeparation();
      this.setScale(dist);
      this.scaleLine.scale.z = -dist;
      this.scaleLine.lookAt(this.xrController2.position);
    }
    const oldScale = scene.scenePivot.scale.x;
    if (this.goalScale !== oldScale) {
      const newScale =
          this.scaleDamper.update(oldScale, this.goalScale, delta, 1);
      scene.scenePivot.scale.set(newScale, newScale, newScale);
    }
  }

  private updatePivotPosition(scene: ModelScene, delta: number) {
    const {scenePivot} = scene;
    const box = this.placementBox!;
    const boundingRadius = scene.boundingSphere.radius;
    const goal = this.goalPosition;
    const position = scenePivot.position;

    let source = ChangeSource.NONE;
    if (!goal.equals(position)) {
      source = ChangeSource.USER_INTERACTION;
      let {x, y, z} = position;
      x = this.xDamper.update(x, goal.x, delta, boundingRadius);
      y = this.yDamper.update(y, goal.y, delta, boundingRadius);
      z = this.zDamper.update(z, goal.z, delta, boundingRadius);
      position.set(x, y, z);

      if (this.xrMode === XRMode.SCREEN_SPACE && !this.isTranslating) {
        const offset = goal.y - y;
        if (this.placementComplete && this.placeOnWall === false) {
          box.offsetHeight = offset / scene.scenePivot.scale.x;
          scene.setShadowOffset(offset);
        } else if (offset === 0) {
          this.placementComplete = true;
          box.show = false;
          scene.setShadowIntensity(AR_SHADOW_INTENSITY);
        }
      }
      if (this.xrMode !== XRMode.SCREEN_SPACE && goal.equals(position)) {
        scene.setShadowIntensity(AR_SHADOW_INTENSITY);
      }

      // For world-space mode after initial placement, don't constrain Y
      // position
      if (this.isWorldSpaceReady()) {
        // Allow full Y-axis movement without floor constraints
        scene.setShadowIntensity(AR_SHADOW_INTENSITY);
      }
    }

    // Handle automatic scaling for world-space mode only during initial
    // placement
    if (this.xrMode === XRMode.WORLD_SPACE &&
        !this.worldSpaceInitialPlacementDone &&
        this.goalScale !== scenePivot.scale.x) {
      const newScale =
          this.scaleDamper.update(scenePivot.scale.x, this.goalScale, delta, 1);
      scenePivot.scale.set(newScale, newScale, newScale);
    }

    scene.updateTarget(delta);

    // Return the source so the caller can use it for camera-change events
    return source;
  }

  private updateYaw(scene: ModelScene, delta: number) {
    // yaw must be updated last, since this also updates the shadow position.
    quaternion.setFromAxisAngle(vector3.set(0, 1, 0), this.goalYaw);
    const angle = scene.scenePivot.quaternion.angleTo(quaternion);
    const angleStep = angle - this.yawDamper.update(angle, 0, delta, Math.PI);
    scene.scenePivot.quaternion.rotateTowards(quaternion, angleStep);
  }

  private updateMenuPanel(scene: ModelScene, box: PlacementBox, delta: number) {
    if (this.menuPanel) {
      this.menuPanel.updateOpacity(delta);
      this.menuPanel.updatePosition(scene.getCamera(), box);
    }
  }

  private applyXRInputToScene(delta: number) {
    const scene = this.presentedScene!;
    const scenePivot = scene.scenePivot;
    const box = this.placementBox!;

    this.updatePlacementBoxOpacity(box, delta);
    this.updateTwoHandInteractionState();
    this.applyXRControllerRotations(scenePivot);
    this.handleScalingInXR(scene, delta);

    if (scenePivot.parent !== scene) {
      // attached to controller instead
      // when moving the model, the menu panel should disapear
      this.updateMenuPanel(scene, box, delta);
      return;
    }

    const source = this.updatePivotPosition(scene, delta);
    this.updateYaw(scene, delta);
    this.dispatchCameraChangeEvent(scene, source);
    this.updateMenuPanel(scene, box, delta);
  }

  private updatePlacementBoxOpacity(box: PlacementBox, delta: number) {
    // Use the new enhanced update method that includes distance scaling and
    // visual state
    const camera = this.presentedScene!.getCamera();
    box.update(delta, camera.position);

    // Update interaction state based on hover
    const over1 = this.hover(this.xrController1!);
    const over2 = this.hover(this.xrController2!);
    const isHovered = (over1 || over2) && !this.isTwoHandInteraction;

    // Set interaction state for visual feedback
    box.setInteractionState(this.isTranslating || this.isRotating, isHovered);
  }

  private updateTwoHandInteractionState() {
    const bothSelected = this.xrController1?.userData.isSelected &&
        this.xrController2?.userData.isSelected;
    this.isTwoHandInteraction = !!bothSelected;
  }

  private applyXRControllerRotations(scenePivot: Object3D) {
    if (!this.isTwoHandInteraction) {
      if (this.xrController1)
        this.applyXRControllerRotation(this.xrController1, scenePivot);
      if (this.xrController2)
        this.applyXRControllerRotation(this.xrController2, scenePivot);
    }
  }

  private dispatchCameraChangeEvent(scene: ModelScene, source: ChangeSource) {
    scene.element.dispatchEvent(new CustomEvent<CameraChangeDetails>(
        'camera-change', {detail: {source}}));
  }

  private updateXRControllerHover() {
    const over1 = this.hover(this.xrController1!);
    const over2 = this.hover(this.xrController2!);
    const isHovered = (over1 || over2) && !this.isTwoHandInteraction;

    // Use the new interaction state system
    if (this.placementBox) {
      this.placementBox.setInteractionState(
          this.isTranslating || this.isRotating, isHovered);
      this.placementBox.show = isHovered;
    }
  }



  /**
   * Enable user interaction for world-space mode after initial automatic
   * placement
   */
  private enableWorldSpaceUserInteraction() {
    // Show placement box to indicate model can be moved
    if (this.placementBox) {
      this.placementBox.show = true;
    }

    // Enable shadow to show model is placed
    if (this.presentedScene) {
      this.presentedScene.setShadowIntensity(AR_SHADOW_INTENSITY);
    }
  }

  private handleFirstView(frame: XRFrame, time: number) {
    // Skip moveToFloor for world-space mode after initial placement to prevent
    // overriding
    if (this.xrMode !== XRMode.WORLD_SPACE ||
        !this.worldSpaceInitialPlacementDone) {
      this.moveToFloor(frame);
    }
    this.processInput(frame);

    const delta = time - this.lastTick!;
    this.applyXRInputToScene(delta);
    this.renderer.preRender(this.presentedScene!, time, delta);
    this.lastTick = time;

    this.presentedScene!.renderShadow(this.threeRenderer);
  }

  /**
   * Only public to make it testable.
   */
  public onWebXRFrame(time: number, frame: XRFrame) {
    if (this.xrMode !== XRMode.SCREEN_SPACE) {
      this.updateXRControllerHover();
    }

    this.frame = frame;
    // increamenets a counter tracking how many frames have been processed
    // sinces the session started
    ++this.frames;
    // refSpace and pose are used to get the user's current position and
    // orientation in the XR session.
    const refSpace = this.threeRenderer.xr.getReferenceSpace()!;
    const pose = frame.getViewerPose(refSpace);

    // Tracking loss Detection.
    // If pos is null, it means the XR system cannot currently track the user's
    // position(e.g., the camera is covered or the env can't be recognized).
    // Checks if we previously throught tracking was working
    // Ensures that we don't report tracking loss too early(sometimes the first
    // few frames can be null as the system initializes).
    if (pose == null && this.tracking === true && this.frames > INIT_FRAMES) {
      this.tracking = false;
      this.dispatchEvent({type: 'tracking', status: ARTracking.NOT_TRACKING});
    }

    // Prevent rendering if there's no valid pose, no scene, or the scene isen't
    // loaded.
    const scene = this.presentedScene;
    if (pose == null || scene == null || !scene.element.loaded) {
      this.threeRenderer.clear();
      return;
    }

    // Tracking REcovery Detection.
    // If tracking was previously lost, but now we have a valid pose, it meanse
    // tracking has been recovered.
    if (this.tracking === false) {
      this.tracking = true;
      this.dispatchEvent({type: 'tracking', status: ARTracking.TRACKING});
    }

    // WebXR may return multiple views, i.e. for headset AR. This
    // isn't really supported at this point, but make a best-effort
    // attempt to render other views also, using the first view
    // as the main viewpoint.
    let isFirstView: boolean = true;
    for (const view of pose.views) {
      this.updateView(view);

      if (isFirstView) {
        this.handleFirstView(frame, time);
        isFirstView = false;
      }

      this.threeRenderer.render(scene, scene.getCamera());
    }
  }

  /**
   * Calculate optimal scale and position for world-space AR presentation
   * Similar to the SVXR:FrameModel approach for consistent model presentation
   *
   * This method implements automatic model framing for world-space AR sessions:
   * 1. Calculates optimal viewing distance based on model size and minimum
   * distance
   * 2. Positions model at a drop angle below camera center for natural viewing
   * 3. Automatically scales model to fit within viewport constraints
   * 4. Ensures consistent presentation across different model sizes
   *
   * Note: This automatic placement only happens on the first session start.
   * After initial placement, users have full control over model position,
   * rotation, and scale.
   */
  private calculateWorldSpaceOptimalPlacement(
      scene: ModelScene, camera: Camera): {position: Vector3, scale: number} {
    // Get model bounding box half extents
    const boundingBox = scene.boundingBox;
    const halfExtent = {
      x: (boundingBox.max.x - boundingBox.min.x) / 2,
      y: (boundingBox.max.y - boundingBox.min.y) / 2,
      z: (boundingBox.max.z - boundingBox.min.z) / 2
    };

    // Compute view distance (with extra padding for model-viewer)
    const placementBoxPadding = PLACEMENT_BOX_EXTRA_PADDING;
    const viewDistance = Math.max(
        MIN_WORLD_SPACE_DISTANCE + placementBoxPadding,
        2 * Math.max(halfExtent.x, halfExtent.z) + placementBoxPadding);

    // Compute ideal view position (drop angle below camera center)
    const dropAngleRad = VIEW_DROP_DEGREES * Math.PI / 180;
    const idealViewPosition = new Vector3(
        0,
        -viewDistance * Math.sin(dropAngleRad),
        -viewDistance * Math.cos(dropAngleRad));

    // Transform ideal view position to world space
    const worldFromCamera = camera.matrixWorld;
    const idealWorldPosition =
        idealViewPosition.clone().applyMatrix4(worldFromCamera);

    // Compute turntable and vertical radii
    const turntableRadius =
        Math.max(halfExtent.x, halfExtent.z) + placementBoxPadding;
    const verticalRadius = halfExtent.y;
    const turntableRadiusLimit = viewDistance * VIEW_RATIO;
    const verticalRadiusLimit = viewDistance * VIEW_RATIO;

    // Compute optimal scale
    const verticalScale =
        verticalRadiusLimit / Math.max(verticalRadius, MIN_MODEL_SIZE);
    const turntableScale =
        turntableRadiusLimit / Math.max(turntableRadius, MIN_MODEL_SIZE);
    const optimalScale = Math.min(verticalScale, turntableScale);

    // Offset so the model's base sits at the ideal world position
    // (subtract scaled half height in Y)

    const finalPosition = idealWorldPosition.clone().sub(
        new Vector3(0, optimalScale * halfExtent.y, 0));

    return {position: finalPosition, scale: optimalScale};
  }

  /**
   * Calculate min/max scale for world-space AR, SVXR-style
   */
  private calculateWorldSpaceScaleLimits(scene: ModelScene) {
    const size = scene.size;
    const largestDimension = Math.max(size.x, size.y, size.z);
    const smallestDimension =
        Math.max(Math.min(size.x, size.y, size.z), MODEL_SIZE_EPSILON);
    const scaleMin =
        MIN_MODEL_SIZE / Math.max(largestDimension, MODEL_SIZE_EPSILON);
    const scaleMax =
        MAX_MODEL_SIZE / Math.max(smallestDimension, MODEL_SIZE_EPSILON);
    // Clamp to initial scale if needed
    this.minScale = Math.min(scaleMin, scaleMax, this.goalScale);
    this.maxScale = Math.max(scaleMin, scaleMax, this.goalScale);
  }
}
