///////////////////////////////////////////////////////////////////////////////
// Copyright (C) 2002-2025, Open Design Alliance (the "Alliance").
// All rights reserved.
//
// This software and its documentation and related materials are owned by
// the Alliance. The software may only be incorporated into application
// programs owned by members of the Alliance, subject to a signed
// Membership Agreement and Supplemental Software License Agreement with the
// Alliance. The structure and organization of this software are the valuable
// trade secrets of the Alliance and its suppliers. The software is also
// protected by copyright law and international treaty provisions. Application
// programs incorporating this software must include the following statement
// with their copyright notices:
//
//   This application incorporates Open Design Alliance software pursuant to a
//   license agreement with Open Design Alliance.
//   Open Design Alliance Copyright (C) 2002-2025 by Open Design Alliance.
//   All rights reserved.
//
// By use of this software, its documentation or related materials, you
// acknowledge and accept the above terms.
///////////////////////////////////////////////////////////////////////////////

import { EventEmitter2 } from "@inweb/eventemitter2";
import { Assembly, Client, File, Model } from "@inweb/client";
import {
  CANVAS_EVENTS,
  CanvasEventMap,
  Dragger,
  IClippingPlane,
  IComponent,
  IEntity,
  IDragger,
  IOrthogonalCamera,
  IOptions,
  IPoint,
  IViewer,
  IViewpoint,
  Options,
  OptionsEventMap,
  ViewerEventMap,
} from "@inweb/viewer-core";
import { IMarkup, IWorldTransform } from "@inweb/markup";

import { draggers } from "./Draggers";
import { commands } from "./Commands";
import { components } from "./Components";

import { loadVisualizeJs } from "./utils";
import { LoaderFactory } from "./Loaders/LoaderFactory";
import { MarkupFactory, MarkupType } from "./Markup/MarkupFactory";

const OVERLAY_VIEW_NAME = "$OVERLAY_VIEW_NAME";

const isExist = (value) => value !== undefined && value !== null;

/**
 * 3D viewer powered by {@link https://cloud.opendesign.com/docs/index.html#/visualizejs | VisualizeJS}
 * library.
 */
export class Viewer
  extends EventEmitter2<ViewerEventMap & CanvasEventMap & OptionsEventMap>
  implements IViewer, IWorldTransform
{
  private _activeDragger: IDragger | null;
  private _components: Array<IComponent>;
  private _enableAutoUpdate: boolean;
  private _isNeedRender: boolean;
  private _isRunAsyncUpdate: boolean;
  private _renderTime: DOMHighResTimeStamp;

  protected _options: Options;
  protected _visualizeJsUrl = "";
  protected _visualizeJs: any;
  protected _visualizeTimestamp: number;
  protected _crossOrigin;

  private canvaseventlistener: (event: Event) => void;

  public canvasEvents: string[];
  private _markup: IMarkup;
  public canvas: HTMLCanvasElement | undefined;

  public _abortController: AbortController | undefined;
  public _abortControllerForRequestMap: Map<string, AbortController> | undefined;
  public _abortControllerForReferences: AbortController | undefined;
  public client: Client | undefined;

  /**
   * @param client - The `Client` instance that is used to load model reference files from the Open Cloud
   *   Server. Do not specify `Client` if you need a standalone viewer instance to view `VSFX` files from
   *   the web or from local computer.
   * @param params - An object containing viewer configuration parameters.
   * @param params.visualizeJsUrl - `VisualizeJS` library URL. Set this URL to use your own library
   *   instance, or specify `undefined` or blank to use the default URL defined by `Viewer.visualize`
   *   library you are using.
   * @param params.crossOrigin - The
   *   {@link https://developer.mozilla.org/docs/Web/HTML/Attributes/crossorigin | crossorigin} content
   *   attribute on `Visalize.js` script element. One of the following values: `""`, `anonymous` or
   *   `use-credentials`.
   * @param params.enableAutoUpdate - Enable auto-update of the viewer after any changes. If the
   *   auto-update is disabled, you need to register an `update` event handler and update the
   *   `VisualizeJS` viewer and active dragger manually. Default is `true`.
   * @param params.markupType - The type of the markup core: `Visualize` (deprecated) or `Konva`. Default
   *   is `Konva`.
   */
  constructor(
    client?: Client,
    params: { visualizeJsUrl?: string; crossOrigin?: string; enableAutoUpdate?: boolean; markupType?: MarkupType } = {}
  ) {
    super();
    this.configure(params);

    this._options = new Options(this);

    this.client = client;

    this._activeDragger = null;
    this._components = [];

    this._renderTime = 0;

    this.canvasEvents = CANVAS_EVENTS.slice();
    this.canvaseventlistener = (event: Event) => this.emit(event);

    this._enableAutoUpdate = params.enableAutoUpdate ?? true;
    this._isNeedRender = false;
    this._isRunAsyncUpdate = false;

    this.render = this.render.bind(this);
    this.update = this.update.bind(this);

    this._markup = MarkupFactory.createMarkup(params.markupType);
  }

  /**
   * Viewer options.
   */
  get options(): IOptions {
    return this._options;
  }

  /**
   * `VisualizeJS` library URL. Use {@link configure | configure()} to change library URL.
   *
   * @readonly
   */
  get visualizeJsUrl(): string {
    return this._visualizeJsUrl;
  }

  /**
   * 2D markup core instance used to create markups.
   *
   * @readonly
   */
  get markup(): IMarkup {
    return this._markup;
  }

  /**
   * Changes the viewer parameters.
   *
   * @param params - An object containing new parameters.
   * @param params.visualizeJsUrl - `VisualizeJS` library URL. Set this URL to use your own library
   *   instance or specify `undefined` or blank to use the default URL defined by `Viewer.visualize`
   *   library you are using.
   * @param params.crossOrigin - The
   *   {@link https://developer.mozilla.org/docs/Web/HTML/Attributes/crossorigin | crossorigin} content
   *   attribute on `Visalize.js` script element. One of the following values: `""`, `anonymous` or
   *   `use-credentials`.
   */
  configure(params: { visualizeJsUrl?: string; crossOrigin?: string }): this {
    this._visualizeJsUrl = params.visualizeJsUrl || "VISUALIZE_JS_URL";
    this._crossOrigin = params.crossOrigin;
    return this;
  }

  /**
   * Loads the `VisualizeJS` module and initializes it with the specified canvas. Call
   * {@link dispose | dispose()} to release allocated resources.
   *
   * Fires:
   *
   * - {@link InitializeEvent | initialize}
   * - {@link InitializeProgressEvent | initializeprogress}
   *
   * @param canvas -
   *   {@link https://developer.mozilla.org/docs/Web/API/HTMLCanvasElement | HTMLCanvasElement} for
   *   `VisualizeJS`.
   * @param onProgress - A callback function that handles events measuring progress of loading of the
   *   `VisualizeJS` library.
   */
  async initialize(canvas: HTMLCanvasElement, onProgress?: (event: ProgressEvent) => void): Promise<this> {
    this.addEventListener("optionschange", (event) => this.syncOptions(event.data));

    if (canvas.style.width === "" && canvas.style.height === "") {
      canvas.style.width = "100%";
      canvas.style.height = "100%";
    }
    canvas.parentElement.style.touchAction = "none";
    canvas.style.touchAction = "none";

    canvas.width = canvas.clientWidth * window.devicePixelRatio;
    canvas.height = canvas.clientHeight * window.devicePixelRatio;

    this._visualizeTimestamp = Date.now();
    const visualizeTimestamp = this._visualizeTimestamp;

    const visualizeJs: any = await loadVisualizeJs(
      this.visualizeJsUrl,
      (event: ProgressEvent) => {
        const { loaded, total } = event;
        if (onProgress) onProgress(new ProgressEvent("progress", { lengthComputable: true, loaded, total }));
        this.emitEvent({ type: "initializeprogress", data: loaded / total, loaded, total });
      },
      { crossOrigin: this._crossOrigin }
    );

    if (visualizeTimestamp !== this._visualizeTimestamp)
      throw new Error(
        "Viewer error: dispose() was called before initialize() completed. Are you using React strict mode?"
      );

    this._visualizeJs = visualizeJs;
    this.visualizeJs.canvas = canvas;
    this.visualizeJs.Viewer.create();
    this.visualizeJs.getViewer().resize(0, canvas.width, canvas.height, 0);

    this.canvas = canvas;
    this.canvasEvents.forEach((x) => canvas.addEventListener(x, this.canvaseventlistener));

    this._markup.initialize(this.canvas, this.canvasEvents, this, this);

    for (const name of components.getComponents().keys()) {
      this._components.push(components.createComponent(name, this));
    }

    this.syncOpenCloudVisualStyle(true);
    this.syncOptions();
    this.syncOverlay();

    this._renderTime = performance.now();
    this.render(this._renderTime);

    this.emitEvent({ type: "initialize" });

    return this;
  }

  dispose(): this {
    this.cancel();
    this.emitEvent({ type: "dispose" });

    this._components.forEach((component: IComponent) => component.dispose());
    this._components = [];

    this.setActiveDragger();
    this.removeAllListeners();

    this._markup.dispose();

    if (this.canvas) {
      this.canvasEvents.forEach((x) => this.canvas.removeEventListener(x, this.canvaseventlistener));
      this.canvas = undefined;
    }

    if (this._visualizeJs) this._visualizeJs.getViewer().clear();
    this._visualizeJs = undefined;
    this._visualizeTimestamp = undefined;

    return this;
  }

  /**
   * Returns `true` if `VisualizeJS` module has been loaded and initialized.
   */
  isInitialized(): boolean {
    return !!this.visualizeJs;
  }

  // internal render/resize routines

  public render(time: DOMHighResTimeStamp) {
    if (!this.visualizeJs) return;

    if (this._isRunAsyncUpdate) return;

    const visViewer = this.visualizeJs.getViewer();
    if (visViewer.isRunningAnimation() || this._isNeedRender) {
      visViewer.update();
      this._activeDragger?.updatePreview?.();
      this._isNeedRender = !visViewer.getActiveDevice().isValid();

      const deltaTime = (time - this._renderTime) / 1000;
      this._renderTime = time;
      this.emitEvent({ type: "render", time, deltaTime });
    }
  }

  public resize(): this {
    if (!this.visualizeJs) return this;

    const { clientWidth, clientHeight } = this.canvas;

    if (!clientWidth || !clientHeight) return this; // <- invisible viewer, or viewer with parent removed

    this.canvas.width = clientWidth * window.devicePixelRatio;
    this.canvas.height = clientHeight * window.devicePixelRatio;

    const visViewer = this.visualizeJs.getViewer();
    visViewer.resize(0, this.canvas.width, this.canvas.height, 0);

    this.update(true);
    this.emitEvent({ type: "resize", width: clientWidth, height: clientHeight });

    return this;
  }

  /**
   * Updates the viewer.
   *
   * Do nothing if the auto-update mode is disabled in the constructor. In this case, register an
   * `update` event handler and update the `Visualize` viewer and active dragger manually.
   *
   * Fires:
   *
   * - {@link UpdateEvent | update}
   *
   * @param force - If `true` updates the viewer immidietly. Otherwise updates on next animation frame.
   *   Default is `false`.
   */
  update(force = false) {
    if (this._enableAutoUpdate) {
      if (force) {
        this.visViewer()?.update();
        this._activeDragger?.updatePreview?.();
      } else {
        this._isNeedRender = true;
      }
    }
    this.emitEvent({ type: "update", data: force });
  }

  private scheduleUpdateAsync(maxScheduleUpdateTimeInMs = 50): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      setTimeout(() => {
        try {
          if (this._enableAutoUpdate) {
            this.visViewer()?.update(maxScheduleUpdateTimeInMs);
            this._activeDragger?.updatePreview?.();
          }
          this.emitEvent({ type: "update", data: false });
          resolve();
        } catch (e) {
          console.error(e);
          reject();
        }
      }, 0);
    });
  }

  /**
   * Updates the viewer asynchronously without locking the user interface. Used to update the viewer
   * after changes that require a long rendering time.
   *
   * Do nothing if the auto-update mode is disabled in the constructor. In this case, register an
   * `update` event handler and update the `VisualizeJS` viewer and active dragger manually.
   *
   * Fires:
   *
   * - {@link UpdateEvent | update}
   *
   * @param maxScheduleUpdateTimeInMs - Maximum time for one update, default 30 ms.
   * @param maxScheduleUpdateCount - Maximum count of scheduled updates.
   */
  async updateAsync(maxScheduleUpdateTimeInMs = 50, maxScheduleUpdateCount = 50): Promise<void> {
    this._isRunAsyncUpdate = true;
    const device = this.visViewer().getActiveDevice();
    try {
      for (let iterationCount = 0; !device.isValid() && iterationCount < maxScheduleUpdateCount; iterationCount++) {
        await this.scheduleUpdateAsync(maxScheduleUpdateTimeInMs);
      }
      await this.scheduleUpdateAsync(maxScheduleUpdateTimeInMs);
    } catch (e) {
      console.error(e);
    } finally {
      this._isRunAsyncUpdate = false;
    }
  }

  /**
   * Returns `VisualizeJS` {@link https://cloud.opendesign.com/docs/index.html#/visualizejs_api | module}
   * instance.
   */
  get visualizeJs(): any {
    return this._visualizeJs;
  }

  /**
   * Returns `VisualizeJS` {@link https://cloud.opendesign.com/docs/index.html#/visualizejs_api | module}
   * instance.
   */
  visLib(): any {
    return this.visualizeJs;
  }

  /**
   * Returns `VisualizeJS` {@link https://cloud.opendesign.com/docs/index.html#/vis/Viewer | Viewer}
   * instance.
   */
  visViewer(): any {
    return this.visualizeJs?.getViewer();
  }

  // update the VisualizeJS options

  syncOpenCloudVisualStyle(isInitializing: boolean): this {
    if (!this.visualizeJs) return this;

    const visLib = this.visLib();
    const visViewer = visLib.getViewer();

    const device = visViewer.getActiveDevice();
    if (device.isNull()) return this;

    const view = device.getActiveView();

    view.enableDefaultLighting(true, visLib.DefaultLightingType.kTwoLights);
    view.setDefaultLightingIntensity(1.25);

    // Visualize.js 25.11 and earlier threw an exception if the style did not exist.
    let visualStyleId;
    try {
      visualStyleId = visViewer.findVisualStyle("OpenCloud");
    } catch {
      visualStyleId = undefined;
    }

    if (!visualStyleId || visualStyleId.isNull()) {
      visualStyleId = visViewer.createVisualStyle("OpenCloud");

      const colorDef = new visLib.OdTvColorDef(66, 66, 66);
      const shadedVsId = visViewer.findVisualStyle("Realistic");

      const visualStylePtr = visualStyleId.openObject();
      visualStylePtr.copyFrom(shadedVsId);
      visualStylePtr.setOptionInt32(visLib.VisualStyleOptions.kFaceModifiers, 0, visLib.VisualStyleOperations.kSet);
      visualStylePtr.setOptionInt32(visLib.VisualStyleOptions.kEdgeModel, 2, visLib.VisualStyleOperations.kSet);
      visualStylePtr.setOptionDouble(visLib.VisualStyleOptions.kEdgeCreaseAngle, 60, visLib.VisualStyleOperations.kSet);
      visualStylePtr.setOptionInt32(visLib.VisualStyleOptions.kEdgeStyles, 0, visLib.VisualStyleOperations.kSet);
      visualStylePtr.setOptionInt32(visLib.VisualStyleOptions.kEdgeModifiers, 8, visLib.VisualStyleOperations.kSet);
      visualStylePtr.setOptionColor(
        visLib.VisualStyleOptions.kEdgeColorValue,
        colorDef,
        visLib.VisualStyleOperations.kSet
      );
      visualStylePtr.delete();
    }

    view.visualStyle = visualStyleId;

    view.delete();
    device.delete();

    return this;
  }

  syncOptions(options: IOptions = this.options): this {
    if (!this.visualizeJs) return this;

    const visLib = this.visLib();
    const visViewer = visLib.getViewer();

    const device = visViewer.getActiveDevice();
    if (device.isNull()) return this;

    if (options.showWCS !== visViewer.getEnableWCS()) {
      visViewer.setEnableWCS(options.showWCS);
    }
    if (options.cameraAnimation !== visViewer.getEnableAnimation()) {
      visViewer.setEnableAnimation(options.cameraAnimation);
    }
    if (options.antialiasing !== visViewer.fxaaAntiAliasing3d) {
      visViewer.fxaaAntiAliasing3d = options.antialiasing;
      visViewer.fxaaQuality = 5;
    }

    if (options.shadows !== visViewer.shadows) {
      visViewer.shadows = options.shadows;

      const canvas = visLib.canvas;
      device.invalidate([0, canvas.clientWidth, canvas.clientHeight, 0]);
    }

    if (options.groundShadow !== visViewer.groundShadow) {
      visViewer.groundShadow = options.groundShadow;
    }

    if (options.ambientOcclusion !== device.getOptionBool(visLib.DeviceOptions.kSSAOEnable)) {
      device.setOptionBool(visLib.DeviceOptions.kSSAOEnable, options.ambientOcclusion);
      device.setOptionBool(visLib.DeviceOptions.kSSAODynamicRadius, true);
      device.setOptionDouble(visLib.DeviceOptions.kSSAORadius, 1);
      device.setOptionInt32(visLib.DeviceOptions.kSSAOLoops, 32);
      device.setOptionDouble(visLib.DeviceOptions.kSSAOPower, 2);
      device.setOptionInt32(visLib.DeviceOptions.kSSAOBlurRadius, 2);

      const activeView = visViewer.activeView;
      activeView.setSSAOEnabled(options.ambientOcclusion);
      activeView.delete();
    }

    if (isExist(options.edgeModel)) {
      const activeView = device.getActiveView();

      const visualStyleId = visViewer.findVisualStyle("OpenCloud");
      const visualStylePtr = visualStyleId.openObject();

      visualStylePtr.setOptionInt32(
        visLib.VisualStyleOptions.kEdgeModel,
        options.edgeModel ? 2 : 0,
        visLib.VisualStyleOperations.kSet
      );

      activeView.visualStyle = visualStyleId;

      visualStylePtr.delete();
      visualStyleId.delete();
      activeView.delete();
    }

    device.delete();

    this.syncHighlightingOptions(options);
    this.update();

    return this;
  }

  syncHighlightingOptions(options: IOptions = this.options): this {
    if (!this.visualizeJs) return this;

    const params = options.enableCustomHighlight ? options : Options.defaults();

    const visLib = this.visLib();
    const visViewer = visLib.getViewer();
    const { Entry, OdTvRGBColorDef } = visLib;

    const highlightStyleId = visViewer.findHighlightStyle("Web_Default");
    const highlightStylePtr = highlightStyleId.openObject();

    if (isExist(params.facesColor)) {
      const color = new OdTvRGBColorDef(params.facesColor.r, params.facesColor.g, params.facesColor.b);
      highlightStylePtr.setFacesColor(Entry.k3D.value | Entry.k3DTop.value, color);
      color.delete();
    }

    if (isExist(params.facesOverlap)) {
      highlightStylePtr.setFacesVisibility(Entry.k3DTop.value, params.facesOverlap);
    }
    if (isExist(params.facesTransparancy)) {
      highlightStylePtr.setFacesTransparency(Entry.k3D.value | Entry.k3DTop.value, params.facesTransparancy);
    }

    if (isExist(params.edgesColor)) {
      const color = new OdTvRGBColorDef(params.edgesColor.r, params.edgesColor.g, params.edgesColor.b);
      highlightStylePtr.setEdgesColor(
        Entry.k3DTop.value | Entry.k3D.value | Entry.k2D.value | Entry.k2DTop.value,
        color
      );
      color.delete();
    }

    if (isExist(params.edgesVisibility)) {
      highlightStylePtr.setEdgesVisibility(
        Entry.k2D.value | Entry.k2DTop.value | Entry.k3DTop.value | Entry.k3D.value,
        params.edgesVisibility
      );
    }
    if (isExist(params.edgesOverlap)) {
      const visibility = !isExist(params.edgesVisibility) ? true : params.edgesVisibility;
      highlightStylePtr.setEdgesVisibility(Entry.k2DTop.value | Entry.k3DTop.value, params.edgesOverlap && visibility);
    }

    const device = visViewer.getActiveDevice();
    if (!device.isNull()) {
      const canvas = visLib.canvas;

      device.invalidate([0, canvas.clientWidth, canvas.clientHeight, 0]);
      device.delete();
    }

    return this;
  }

  get draggers(): string[] {
    return [...draggers.getDraggers().keys()];
  }

  get components(): string[] {
    return [...components.getComponents().keys()];
  }

  /**
   * Deprecated since `25.12`. Use {@link draggers.registerDragger} instead.
   */
  public registerDragger(name: string, dragger: typeof Dragger): void {
    console.warn(
      "Viewer.registerDragger() has been deprecated since 25.12 and will be removed in a future release, use draggers('visualizejs').registerDragger() instead."
    );
    draggers.registerDragger(name, (viewer: IViewer) => new dragger(viewer));
  }

  activeDragger(): IDragger | null {
    return this._activeDragger;
  }

  setActiveDragger(name = ""): IDragger | null {
    if (!this._activeDragger || this._activeDragger.name !== name) {
      const oldDragger = this._activeDragger;
      let newDragger = null;

      if (this._activeDragger) {
        this._activeDragger.dispose();
        this._activeDragger = null;
      }
      if (this.visualizeJs) {
        newDragger = draggers.createDragger(name, this);
        if (newDragger) {
          this._activeDragger = newDragger;
          this._activeDragger.initialize?.();
        }
      }
      const canvas = this.canvas;
      if (canvas) {
        if (oldDragger) canvas.classList.remove(`oda-cursor-${oldDragger.name.toLowerCase()}`);
        if (newDragger) canvas.classList.add(`oda-cursor-${newDragger.name.toLowerCase()}`);
      }

      this.emitEvent({ type: "changeactivedragger", data: name });
      this.update();
    }
    return this._activeDragger;
  }

  resetActiveDragger(): void {
    const dragger = this._activeDragger;
    if (dragger) {
      this.setActiveDragger();
      this.setActiveDragger(dragger.name);
    }
  }

  getComponent(name: string): IComponent {
    return this._components.find((component) => component.name === name);
  }

  clearSlices(): void {
    if (!this.visualizeJs) return;

    const visViewer = this.visViewer();
    const activeView = visViewer.activeView;
    activeView.removeCuttingPlanes();
    activeView.delete();

    this.update();
  }

  clearOverlay(): void {
    if (!this.visualizeJs) return;

    this._markup.clearOverlay();
    this.update();
  }

  syncOverlay(): void {
    if (!this.visualizeJs) return;

    const visViewer = this.visViewer();
    const activeView = visViewer.activeView;

    let overlayView = visViewer.getViewByName(OVERLAY_VIEW_NAME);
    if (!overlayView) {
      const markupModel = visViewer.getMarkupModel();
      const pDevice = visViewer.getActiveDevice();

      overlayView = pDevice.createView(OVERLAY_VIEW_NAME, false);
      overlayView.addModel(markupModel);

      activeView.addSibling(overlayView);
      pDevice.addView(overlayView);
    }

    overlayView.viewPosition = activeView.viewPosition;
    overlayView.viewTarget = activeView.viewTarget;
    overlayView.upVector = activeView.upVector;
    overlayView.viewFieldWidth = activeView.viewFieldWidth;
    overlayView.viewFieldHeight = activeView.viewFieldHeight;

    const viewPort = overlayView.getViewport();
    overlayView.setViewport(viewPort.lowerLeft, viewPort.upperRight);
    overlayView.vportRect = activeView.vportRect;

    this._markup.syncOverlay();
    this.update();
  }

  is3D(): boolean {
    if (!this.visualizeJs) return false;

    const visViewer = this.visViewer();
    const ext = visViewer.getActiveExtents();
    const min = ext.min();
    const max = ext.max();
    const extHeight = max[2] - min[2];
    return extHeight !== 0;

    //return visViewer.activeView.upVector[1] >= 0.95;
  }

  screenToWorld(position: { x: number; y: number }): { x: number; y: number; z: number } {
    if (!this.visualizeJs) return { x: position.x, y: position.y, z: 0 };

    const activeView = this.visViewer().activeView;
    const worldPoint = activeView.transformScreenToWorld(
      position.x * window.devicePixelRatio,
      position.y * window.devicePixelRatio
    );

    const result = { x: worldPoint[0], y: worldPoint[1], z: worldPoint[2] };

    activeView.delete();

    return result;
  }

  worldToScreen(position: { x: number; y: number; z: number }): { x: number; y: number } {
    if (!this.visualizeJs) return { x: position.x, y: position.y };

    const activeView = this.visViewer().activeView;
    const devicePoint = activeView.transformWorldToScreen(position.x, position.y, position.z);

    const result = { x: devicePoint[0] / window.devicePixelRatio, y: devicePoint[1] / window.devicePixelRatio };

    activeView.delete();

    return result;
  }

  getScale(): { x: number; y: number; z: number } {
    const result = { x: 1.0, y: 1.0, z: 1.0 };

    const projMatrix = this.visViewer().activeView.projectionMatrix;
    const tolerance = 1.0e-6;

    const x = projMatrix.get(0, 0);
    if (x > tolerance || x < -tolerance) result.x = 1 / x;

    const y = projMatrix.get(1, 1);
    if (y > tolerance || y < -tolerance) result.y = 1 / y;

    const z = projMatrix.get(2, 2);
    if (z > tolerance || z < -tolerance) result.z = 1 / z;

    return result;
  }

  getSelected(): string[] {
    return this.executeCommand("getSelected");
  }

  setSelected(handles?: string[]): void {
    this.executeCommand("setSelected", handles);
  }

  clearSelected(): void {
    this.executeCommand("clearSelected");
  }

  hideSelected(): void {
    this.executeCommand("hideSelected");
  }

  isolateSelected(): void {
    this.executeCommand("isolateSelected");
  }

  showAll(): void {
    this.executeCommand("showAll");
  }

  explode(index = 0): void {
    this.executeCommand("explode", index);
  }

  collect(): void {
    this.executeCommand("collect");
  }

  // Internal loading routines

  async loadReferences(model: Model | File | Assembly): Promise<this> {
    if (!this.visualizeJs) return this;
    if (!this.client) return this;

    const abortController = new AbortController();
    this._abortControllerForReferences?.abort();
    this._abortControllerForReferences = abortController;

    let references: any[] = [];
    await model
      .getReferences(abortController.signal)
      .then((data) => (references = data.references))
      .catch((e) => console.error("Cannot load model references.", e));

    for (const file of references) {
      await this.client
        .downloadFile(file.id, undefined, abortController.signal)
        .then((arrayBuffer) => this.visualizeJs?.getViewer().addEmbeddedFile(file.name, new Uint8Array(arrayBuffer)))
        .catch((e) => console.error(`Cannot load reference file ${file.name}.`, e));
    }

    return this;
  }

  applyModelTransformMatrix(model: Model | Assembly) {
    this.executeCommand("applyModelTransform", model);
  }

  applySceneGraphSettings(options = this.options) {
    if (!this.visualizeJs) return;

    const visLib = this.visLib();
    const visViewer = visLib.getViewer();

    const device = visViewer.getActiveDevice();
    if (isExist(options.sceneGraph)) {
      device.setOptionBool(visLib.DeviceOptions.kDelaySceneGraphProc, !options.sceneGraph);
    }
    // if (options.enablePartialMode && visLib.HpTrc.Usd >= visViewer.memoryLimit) {
    //   device.setOptionBool(visLib.DeviceOptions.kDelaySceneGraphProc, true);
    // }
    device.delete();

    this.update();
  }

  /**
   * Loads a file from Open Cloud Server into the viewer.
   *
   * The file geometry data on the server must be converted to `VSFX` format.
   *
   * To open a large file, enable {@link IOptions.enablePartialMode | partial streaming} mode before
   * opening (see example below).
   *
   * This method requires a `Client` instance to be specified when creating the viewer to load model
   * reference files from the Open Cloud Server. For a standalone viewer instance use
   * {@link openVsfFile | openVsfFile()} or {@link openVsfxFile | openVsfxFile()}.
   *
   * If there was an active dragger before opening the file, it will be deactivated. After opening the
   * file, you must manually activate the required dragger.
   *
   * Fires:
   *
   * - {@link OpenEvent | open}
   * - {@link GeometryStartEvent | geometrystart}
   * - {@link GeometryProgressEvent | geometryprogress}
   * - {@link DatabaseChunkEvent | databasechunk}
   * - {@link GeometryChunkEvent | geometrychunk}
   * - {@link GeometryEndEvent | geometryend}
   * - {@link GeometryErrorEvent | geometryerror}
   *
   * @example Using partial streaming mode to open a large file from a server.
   *
   * ```javascript
   * viewer.options.enableStreamingMode = true;
   * viewer.options.enablePartialMode = true;
   * await viewer.open(file);
   * ```
   *
   * @param file - File, assembly or specific model to load. If a `File` instance with multiple models is
   *   specified, the default model will be loaded. If there is no default model, first availiable model
   *   will be loaded.
   */
  async open(file: File | Assembly | Model): Promise<this> {
    if (!this.visualizeJs) return this;

    this.cancel();
    this.clear();

    this.emitEvent({ type: "open", file, model: file });

    let model: Model | undefined = undefined;
    if (file) {
      const models = (await file.getModels()) || [];
      model = models.find((model: Model) => model.default) || models[0];
    }
    if (!model) throw new Error("No default model found");

    const overrideOptions = new Options();
    overrideOptions.data = this._options.data;
    if (file.type === ".rcs" && !overrideOptions.enablePartialMode) {
      console.log("Partial streaming mode is forced for RCS file");
      overrideOptions.enableStreamingMode = true;
      overrideOptions.enablePartialMode = true;
    }

    const loaderFactory = new LoaderFactory();
    const loader = loaderFactory.create(this, model, overrideOptions);

    await this.loadReferences(model);
    await loader.load();

    if (this.visualizeJs) {
      this.applyModelTransformMatrix(model);
      this.applySceneGraphSettings();
    }

    return this;
  }

  /**
   * Loads a `VSF` file into the viewer.
   *
   * This method does not support {@link IOptions.enableStreamingMode | streaming} or
   * {@link IOptions.enablePartialMode | partial streaming} mode.
   *
   * If there was an active dragger before opening the file, it will be deactivated. After opening the
   * file, you must manually activate the required dragger.
   *
   * Fires:
   *
   * - {@link OpenEvent | open}
   * - {@link GeometryStartEvent | geometrystart}
   * - {@link GeometryProgressEvent | geometryprogress}
   * - {@link DatabaseChunkEvent | databasechunk}
   * - {@link GeometryEndEvent | geometryend}
   * - {@link GeometryErrorEvent | geometryerror}
   *
   * @param buffer - Binary data buffer to load.
   */
  openVsfFile(buffer: Uint8Array | ArrayBuffer): this {
    if (!this.visualizeJs) return this;

    this.cancel();
    this.clear();

    this.emitEvent({ type: "open", buffer });

    try {
      this.emitEvent({ type: "geometrystart", buffer });

      const visLib = this.visLib();
      const visViewer = visLib.getViewer();

      const data = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
      visViewer.parseFile(data);

      this.syncOpenCloudVisualStyle(false);
      this.syncOptions();
      this.syncOverlay();
      this.resize();

      this.emitEvent({ type: "geometryprogress", data: 1, buffer });
      this.emitEvent({ type: "databasechunk", data, buffer });
      this.emitEvent({ type: "geometryend", buffer });
    } catch (error: any) {
      this.emitEvent({ type: "geometryerror", data: error, buffer });
      throw error;
    }

    return this;
  }

  /**
   * Loads a `VSFX` file into the viewer.
   *
   * This method does not support {@link IOptions.enableStreamingMode | streaming} or
   * {@link IOptions.enablePartialMode | partial streaming} mode.
   *
   * If there was an active dragger before opening the file, it will be deactivated. After opening the
   * file, you must manually activate the required dragger.
   *
   * Fires:
   *
   * - {@link OpenEvent | open}
   * - {@link GeometryStartEvent | geometrystart}
   * - {@link GeometryProgressEvent | geometryprogress}
   * - {@link DatabaseChunkEvent | databasechunk}
   * - {@link GeometryEndEvent | geometryend}
   * - {@link GeometryErrorEvent | geometryerror}
   *
   * @param buffer - Binary data buffer to load.
   */
  openVsfxFile(buffer: Uint8Array | ArrayBuffer): this {
    if (!this.visualizeJs) return this;

    this.cancel();
    this.clear();

    this.emitEvent({ type: "open", buffer });

    try {
      this.emitEvent({ type: "geometrystart", buffer });

      const visLib = this.visLib();
      const visViewer = visLib.getViewer();

      const data = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
      visViewer.parseVsfx(data);

      this.syncOpenCloudVisualStyle(false);
      this.syncOptions();
      this.syncOverlay();
      this.resize();

      this.emitEvent({ type: "geometryprogress", data: 1, buffer });
      this.emitEvent({ type: "databasechunk", data, buffer });
      this.emitEvent({ type: "geometryend", buffer });
    } catch (error: any) {
      this.emitEvent({ type: "geometryerror", data: error, buffer });
      throw error;
    }

    return this;
  }

  cancel(): this {
    this._abortControllerForReferences?.abort();
    this._abortControllerForReferences = undefined;

    this._abortController?.abort();
    this._abortController = undefined;

    this._abortControllerForRequestMap?.forEach((controller) => controller.abort());
    this._abortControllerForRequestMap = undefined;

    this.emitEvent({ type: "cancel" });

    return this;
  }

  clear(): this {
    if (!this.visualizeJs) return this;

    const visLib = this.visLib();
    const visViewer = visLib.getViewer();

    this.setActiveDragger();
    this.clearSlices();
    this.clearOverlay();
    this.clearSelected();

    visViewer.clear();
    visViewer.createLocalDatabase();

    this.syncOpenCloudVisualStyle(true);
    this.syncOptions();
    this.syncOverlay();
    this.resize();

    this.emitEvent({ type: "clear" });

    return this;
  }

  /**
   * Deprecated since `25.11`. Use {@link IMarkup.getMarkupColor | markup.getMarkupColor()} instead.
   */
  getMarkupColor(): { r: number; g: number; b: number } {
    console.warn(
      "Viewer.getMarkupColor() has been deprecated since 25.11 and will be removed in a future release, use Viewer.markup.getMarkupColor() instead."
    );
    return this._markup.getMarkupColor();
  }

  /**
   * Deprecated since `25.11`. Use {@link IMarkup.setMarkupColor | markup.setMarkupColor()} instead.
   */
  setMarkupColor(r = 255, g = 0, b = 0): void {
    console.warn(
      "Viewer.setMarkupColor() has been deprecated since 25.11 and will be removed in a future release, use Viewer.markup.setMarkupColor() instead."
    );
    this._markup.setMarkupColor(r, g, b);
  }

  /**
   * Deprecated since `25.11`. Use {@link IMarkup.colorizeAllMarkup | markup.colorizeAllMarkup()} instead.
   */
  colorizeAllMarkup(r = 255, g = 0, b = 0): void {
    console.warn(
      "Viewer.colorizeAllMarkup() has been deprecated since 25.11 and will be removed in a future release, use Viewer.markup.colorizeAllMarkup() instead."
    );
    this._markup.colorizeAllMarkup(r, g, b);
  }

  /**
   * Deprecated since `25.11`. Use
   * {@link IMarkup.colorizeSelectedMarkups | markup.colorizeSelectedMarkups()} instead.
   */
  colorizeSelectedMarkups(r = 255, g = 0, b = 0): void {
    this._markup.colorizeSelectedMarkups(r, g, b);
  }

  /**
   * Adds an empty `Visualize` markup entity to the overlay.
   */
  addMarkupEntity(entityName: string) {
    if (!this.visualizeJs) return null;

    this.syncOverlay();

    const visViewer = this.visViewer();
    const model = visViewer.getMarkupModel();
    const entityId = model.appendEntity(entityName);
    const entityPtr = entityId.openObject();

    const color = this.getMarkupColor();
    entityPtr.setColor(color.r, color.g, color.b);
    entityPtr.setLineWeight(2);
    entityPtr.delete();

    this.update();

    return entityId;
  }

  drawViewpoint(viewpoint: IViewpoint): void {
    if (!this.visualizeJs) return;

    const draggerName = this._activeDragger?.name;

    this.setActiveDragger();
    this.clearSlices();
    this.clearOverlay();

    this.clearSelected();
    this.showAll();
    this.explode();

    this.setOrthogonalCameraSettings(viewpoint.orthogonal_camera);
    this.setClippingPlanes(viewpoint.clipping_planes);
    this.setSelection(viewpoint.selection);
    this._markup.setViewpoint(viewpoint);

    this.setActiveDragger(draggerName);
    this.emitEvent({ type: "drawviewpoint", data: viewpoint });
    this.update();
  }

  createViewpoint(): IViewpoint {
    if (!this.visualizeJs) return {};

    const viewpoint: IViewpoint = {};

    viewpoint.orthogonal_camera = this.getOrthogonalCameraSettings();
    viewpoint.clipping_planes = this.getClippingPlanes();
    viewpoint.selection = this.getSelection();
    viewpoint.description = new Date().toDateString();
    this._markup.getViewpoint(viewpoint);

    this.emitEvent({ type: "createviewpoint", data: viewpoint });

    return viewpoint;
  }

  private getPoint3dFromArray(array: number[]) {
    return { x: array[0], y: array[1], z: array[2] };
  }

  private getLogicalPoint3dAsArray(point3d: IPoint) {
    return [point3d.x, point3d.y, point3d.z];
  }

  private getOrthogonalCameraSettings(): IOrthogonalCamera {
    const visViewer = this.visViewer();
    const activeView = visViewer.activeView;

    return {
      view_point: this.getPoint3dFromArray(activeView.viewPosition),
      direction: this.getPoint3dFromArray(activeView.viewTarget),
      up_vector: this.getPoint3dFromArray(activeView.upVector),
      field_width: activeView.viewFieldWidth,
      field_height: activeView.viewFieldHeight,
      view_to_world_scale: 1,
    };
  }

  private setOrthogonalCameraSettings(settings: IOrthogonalCamera) {
    const visViewer = this.visViewer();
    const activeView = visViewer.activeView;

    if (settings) {
      activeView.setView(
        this.getLogicalPoint3dAsArray(settings.view_point),
        this.getLogicalPoint3dAsArray(settings.direction),
        this.getLogicalPoint3dAsArray(settings.up_vector),
        settings.field_width,
        settings.field_height,
        true
      );
      this.syncOverlay();
    }
  }

  private getClippingPlanes(): IClippingPlane[] {
    const visViewer = this.visViewer();
    const activeView = visViewer.activeView;

    const clipping_planes = [];
    for (let i = 0; i < activeView.numCuttingPlanes(); i++) {
      const cuttingPlane = activeView.getCuttingPlane(i);

      const clipping_plane = {
        location: this.getPoint3dFromArray(cuttingPlane.getOrigin()),
        direction: this.getPoint3dFromArray(cuttingPlane.normal()),
      };

      clipping_planes.push(clipping_plane);
    }

    return clipping_planes;
  }

  private setClippingPlanes(clipping_planes: IClippingPlane[]) {
    if (clipping_planes) {
      const visViewer = this.visViewer();
      const activeView = visViewer.activeView;

      for (const clipping_plane of clipping_planes) {
        const cuttingPlane = new (this.visLib().OdTvPlane)();
        cuttingPlane.set(
          this.getLogicalPoint3dAsArray(clipping_plane.location),
          this.getLogicalPoint3dAsArray(clipping_plane.direction)
        );

        activeView.addCuttingPlane(cuttingPlane);
        activeView.setEnableCuttingPlaneFill(true, 0x66, 0x66, 0x66);
      }
    }
  }

  private getSelection(): IEntity[] {
    return this.getSelected().map((handle) => ({ handle }));
  }

  private setSelection(selection: IEntity[]) {
    this.setSelected(selection?.map((component) => component.handle));
  }

  /**
   * Executes the command denoted by the given command. If the command is not found, tries to set active
   * dragger with the specified name.
   *
   * The following commands are available by default:
   *
   * - `applyModelTransform`
   * - `autoTransformAllModelsToCentralPoint`
   * - `clearMarkup`
   * - `clearSelected`
   * - `clearSlices`
   * - `createPreview`
   * - `explode`
   * - `getDefaultViewPositions`
   * - `getModels`
   * - `getSelected`
   * - `hideSelected`
   * - `isolateSelected`
   * - `regenerateAll`
   * - `resetView`
   * - `selectModel`
   * - `setActiveDragger`
   * - `setDefaultViewPosition`
   * - `setMarkupColor`
   * - `setSelected`
   * - `showAll`
   * - `zoomToExtents`
   * - `zoomToObjects`
   * - `zoomToSelected`
   *
   * To register custom command use the {@link commands.registerCommand}.
   *
   * @param id - Command ID or dragger name.
   * @param args - Parameters passed to the command handler function.
   * @returns Returns the result of the command handler function or new active dragger instance. Returns
   *   `undefined` if neither the command nor the dragger exists.
   */
  executeCommand(id: string, ...args: any[]): any {
    return commands.executeCommand(id, this, ...args);
  }

  public deviceAutoRegeneration() {
    const visViewer = this.visViewer();
    const device = visViewer.getActiveDevice();

    const coef = device.getOptionDouble(this.visLib().DeviceOptions.kRegenCoef);
    if (coef > 1.0) {
      visViewer.regenAll();
      this.update();
    }
  }
}
