///////////////////////////////////////////////////////////////////////////////
// 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 { Camera, MathUtils, Matrix4, Object3D, Vector2, Vector3, Vector4 } from "three";

import type { Viewer } from "../Viewer";
import { OrbitDragger } from "./OrbitDragger";
import { convertUnits } from "../measurement/UnitConverter";
import { formatDistance } from "../measurement/UnitFormatter";
import { Snapper } from "../measurement/Snapper";

const _downPoint = new Vector2();

export class MeasureLineDragger extends OrbitDragger {
  private overlay: MeasureOverlay;
  private line: MeasureLine;
  private snapper: Snapper;
  private objects: Object3D[];
  private scale = 1.0;
  private units = "";
  private precision: any = 2;

  constructor(viewer: Viewer) {
    super(viewer);

    this.overlay = new MeasureOverlay(viewer.camera, viewer.canvas);
    this.overlay.attach();

    this.line = new MeasureLine(this.overlay, this.scale, this.units, this.precision);
    this.overlay.addLine(this.line);

    this.snapper = new Snapper(viewer.camera, viewer.renderer, viewer.canvas);

    this.objects = [];
    this.updateObjects();

    this.updateUnits();

    this.viewer.canvas.addEventListener("pointerdown", this.onPointerDown);
    this.viewer.canvas.addEventListener("pointermove", this.onPointerMove);
    this.viewer.canvas.addEventListener("pointerup", this.onPointerUp);
    this.viewer.canvas.addEventListener("pointercancel", this.onPointerCancel);
    this.viewer.canvas.addEventListener("pointerleave", this.onPointerLeave);

    this.viewer.addEventListener("render", this.renderOverlay);
    this.viewer.addEventListener("hide", this.updateObjects);
    this.viewer.addEventListener("isolate", this.updateObjects);
    this.viewer.addEventListener("show", this.updateObjects);
    this.viewer.addEventListener("showall", this.updateObjects);
    this.viewer.addEventListener("changecameramode", this.updateSnapperCamera);
    this.viewer.addEventListener("optionschange", this.updateUnits);
  }

  override dispose() {
    this.viewer.canvas.removeEventListener("pointerdown", this.onPointerDown);
    this.viewer.canvas.removeEventListener("pointermove", this.onPointerMove);
    this.viewer.canvas.removeEventListener("pointerup", this.onPointerUp);
    this.viewer.canvas.removeEventListener("pointercancel", this.onPointerCancel);
    this.viewer.canvas.removeEventListener("pointerleave", this.onPointerLeave);

    this.viewer.removeEventListener("render", this.renderOverlay);
    this.viewer.removeEventListener("hide", this.updateObjects);
    this.viewer.removeEventListener("isolate", this.updateObjects);
    this.viewer.removeEventListener("show", this.updateObjects);
    this.viewer.removeEventListener("showall", this.updateObjects);
    this.viewer.removeEventListener("changecameramode", this.updateSnapperCamera);
    this.viewer.removeEventListener("optionschange", this.updateUnits);

    this.objects.length = 0;

    this.overlay.detach();
    this.overlay.dispose();

    super.dispose();
  }

  onPointerDown = (event: PointerEvent) => {
    if (event.button !== 0) return;

    const mouse = this.snapper.getMousePosition(event, _downPoint);
    this.line.startPoint = this.snapper.getSnapPoint(mouse, this.objects);
    this.line.render();

    this.viewer.canvas.setPointerCapture(event.pointerId);
    this.orbit.enabled = !this.line.startPoint;
  };

  onPointerMove = (event: PointerEvent) => {
    if (this.orbit.enabled && this.orbit.state !== -1) return;

    const mouse = this.snapper.getMousePosition(event, _downPoint);
    const snapPoint = this.snapper.getSnapPoint(mouse, this.objects);
    if (snapPoint && this.line.endPoint && snapPoint.equals(this.line.endPoint)) return;

    this.line.endPoint = snapPoint;
    this.line.render();

    if (this.line.startPoint) this.changed = true; // <- to prevent context menu
  };

  onPointerUp = (event: PointerEvent) => {
    if (this.line.startPoint && this.line.endPoint && this.line.getDistance() > 0) {
      this.line = new MeasureLine(this.overlay, this.scale, this.units, this.precision);
      this.overlay.addLine(this.line);
    } else {
      this.line.startPoint = undefined;
      this.line.endPoint = undefined;
      this.line.render();
    }

    this.viewer.canvas.releasePointerCapture(event.pointerId);
    this.orbit.enabled = true;
  };

  onPointerCancel = (event: PointerEvent) => {
    this.viewer.canvas.dispatchEvent(new PointerEvent("pointerup", event));
  };

  onPointerLeave = () => {
    this.line.endPoint = undefined;
    this.line.render();
  };

  clearOverlay = () => {
    this.overlay.clear();

    this.line = new MeasureLine(this.overlay, this.scale, this.units, this.precision);
    this.overlay.addLine(this.line);
  };

  renderOverlay = () => {
    this.overlay.render();
  };

  updateObjects = () => {
    this.objects.length = 0;
    this.viewer.models.forEach((model) => {
      model.getVisibleObjects().forEach((object) => this.objects.push(object));
    });
  };

  updateSnapperCamera = () => {
    this.snapper.camera = this.viewer.camera;
    this.overlay.camera = this.viewer.camera;
  };

  updateUnits = () => {
    const model = this.viewer.models[0];
    const units = this.viewer.options.rulerUnit ?? "Default";
    const precision = this.viewer.options.rulerPrecision ?? "Default";

    if (units === "Default") {
      this.scale = model.getUnitScale();
      this.units = model.getUnitString();
    } else {
      this.scale = convertUnits(model.getUnits(), units, 1);
      this.units = units;
    }

    if (precision === "Default") {
      this.precision = model.getPrecision();
    } else {
      this.precision = precision;
    }

    this.overlay.updateLineUnits(this.scale, this.units, this.precision);
  };
}

class MeasureOverlay {
  public camera: Camera;
  public canvas: HTMLCanvasElement;
  public container: HTMLElement;
  public lines: MeasureLine[] = [];
  public projector: MeasureProjector;
  private resizeObserver: ResizeObserver;

  constructor(camera: Camera, canvas: HTMLCanvasElement) {
    this.camera = camera;
    this.canvas = canvas;
    this.projector = new MeasureProjector(camera, canvas);
    this.resizeObserver = new ResizeObserver(this.resizeContainer);
  }

  dispose() {
    this.clear();
  }

  attach() {
    this.container = document.createElement("div");
    this.container.id = "measure-container";
    this.container.style.position = "absolute";
    this.container.style.outline = "none";
    this.container.style.pointerEvents = "none";
    this.container.style.overflow = "hidden";

    if (!this.canvas.parentElement) return;

    this.canvas.parentElement.appendChild(this.container);
    this.resizeObserver.observe(this.canvas);
  }

  detach() {
    this.resizeObserver.disconnect();

    this.container.remove();
    this.container = undefined;
  }

  clear() {
    this.lines.forEach((line) => line.dispose());
    this.lines.length = 0;
  }

  render() {
    this.projector.setFromCamera(this.camera);
    this.lines.forEach((line) => line.render());
  }

  update() {
    this.lines.forEach((line) => line.update());
  }

  addLine(line: MeasureLine) {
    this.lines.push(line);
  }

  removeLine(line: MeasureLine) {
    this.lines = this.lines.filter((x) => x !== line);
  }

  updateLineUnits(scale: number, units: string, precision: any) {
    this.lines.forEach((line) => {
      line.scale = scale;
      line.units = units;
      line.precision = precision;
    });
  }

  resizeContainer = () => {
    const { offsetLeft, offsetTop, offsetWidth, offsetHeight } = this.canvas;

    if (!offsetWidth || !offsetHeight) return; // <- invisible canvas, or canvas with parent removed

    this.container.style.left = `${offsetLeft}px`;
    this.container.style.top = `${offsetTop}px`;

    this.container.style.width = `${offsetWidth}px`;
    this.container.style.height = `${offsetHeight}px`;
  };
}

const _middlePoint = new Vector3();

class MeasureLine {
  private overlay: MeasureOverlay;

  private elementStartPoint: HTMLElement;
  private elementEndPoint: HTMLElement;
  private elementLine: HTMLElement;
  private elementLabel: HTMLElement;

  public startPoint: Vector3;
  public endPoint: Vector3;

  public id = MathUtils.generateUUID();
  public scale: number;
  public units: string;
  public precision: any;
  public size = 10.0;
  public lineWidth = 2;

  public style = {
    border: "2px solid #FFFFFF",
    background: "#009bff",
    boxShadow: "0 0 10px rgba(0,0,0,0.5)",
    color: "white",
    font: "1rem system-ui",
  };

  constructor(overlay: MeasureOverlay, scale: number, units: string, precision: any) {
    this.overlay = overlay;
    this.scale = scale;
    this.units = units;
    this.precision = precision;

    this.elementStartPoint = overlay.container.appendChild(document.createElement("div"));
    this.elementEndPoint = overlay.container.appendChild(document.createElement("div"));
    this.elementLine = overlay.container.appendChild(document.createElement("div"));
    this.elementLabel = overlay.container.appendChild(document.createElement("div"));

    this.update();
  }

  dispose() {
    this.elementStartPoint.remove();
    this.elementEndPoint.remove();
    this.elementLine.remove();
    this.elementLabel.remove();

    this.elementStartPoint = undefined;
    this.elementEndPoint = undefined;
    this.elementLine = undefined;
    this.elementLabel = undefined;
  }

  render() {
    const projector = this.overlay.projector;

    if (this.startPoint) {
      const { point, visible } = projector.projectPoint(this.startPoint);

      this.elementStartPoint.style.display = visible ? "block" : "none";
      this.elementStartPoint.style.left = `${point.x}px`;
      this.elementStartPoint.style.top = `${point.y}px`;
    } else {
      this.elementStartPoint.style.display = "none";
    }

    if (this.endPoint) {
      const { point, visible } = projector.projectPoint(this.endPoint);

      this.elementEndPoint.style.display = visible ? "block" : "none";
      this.elementEndPoint.style.left = `${point.x}px`;
      this.elementEndPoint.style.top = `${point.y}px`;
    } else {
      this.elementEndPoint.style.display = "none";
    }

    if (this.startPoint && this.endPoint) {
      const { point1, point2, visible } = projector.projectLine(this.startPoint, this.endPoint);

      point2.sub(point1);
      const angle = point2.angle();
      const width = point2.length();

      this.elementLine.style.display = visible ? "block" : "none";
      this.elementLine.style.left = `${point1.x}px`;
      this.elementLine.style.top = `${point1.y}px`;
      this.elementLine.style.width = `${width}px`;
      this.elementLine.style.transform = `translate(0px, ${-this.lineWidth / 2}px) rotate(${angle}rad)`;
    } else {
      this.elementLine.style.display = "none";
    }

    if (this.startPoint && this.endPoint) {
      _middlePoint.lerpVectors(this.startPoint, this.endPoint, 0.5);
      const { point, visible } = projector.projectPoint(_middlePoint);

      const distance = this.getDistance();

      this.elementLabel.style.display = visible && distance > 0 ? "block" : "none";
      this.elementLabel.style.left = `${point.x}px`;
      this.elementLabel.style.top = `${point.y}px`;
      this.elementLabel.innerHTML = formatDistance(distance, this.units, this.precision);
    } else {
      this.elementLabel.style.display = "none";
    }
  }

  update() {
    this.elementStartPoint.id = `markup-dot-start-${this.id}`;
    this.elementStartPoint.style.position = "absolute";
    this.elementStartPoint.style.zIndex = "2";
    this.elementStartPoint.style.width = `${this.size}px`;
    this.elementStartPoint.style.height = `${this.size}px`;
    this.elementStartPoint.style.border = this.style.border;
    this.elementStartPoint.style.borderRadius = `${this.size}px`;
    this.elementStartPoint.style.background = this.style.background;
    this.elementStartPoint.style.boxShadow = this.style.boxShadow;
    this.elementStartPoint.style.transform = "translate(-50%, -50%)";

    this.elementEndPoint.id = `markup-dot-end-${this.id}`;
    this.elementEndPoint.style.position = "absolute";
    this.elementEndPoint.style.zIndex = "2";
    this.elementEndPoint.style.width = `${this.size}px`;
    this.elementEndPoint.style.height = `${this.size}px`;
    this.elementEndPoint.style.border = this.style.border;
    this.elementEndPoint.style.borderRadius = `${this.size}px`;
    this.elementEndPoint.style.background = this.style.background;
    this.elementEndPoint.style.boxShadow = this.style.boxShadow;
    this.elementEndPoint.style.transform = "translate(-50%, -50%)";

    this.elementLine.id = `markup-line-${this.id}`;
    this.elementLine.style.position = "absolute";
    this.elementLine.style.zIndex = "1";
    this.elementLine.style.height = `${this.lineWidth}px`;
    this.elementLine.style.background = this.style.background;
    this.elementLine.style.boxShadow = this.style.boxShadow;
    this.elementLine.style.transformOrigin = `0px ${this.lineWidth / 2}px`;

    this.elementLabel.id = `markup-label-${this.id}`;
    this.elementLabel.style.position = "absolute";
    this.elementLabel.style.zIndex = "3";
    this.elementLabel.style.padding = "2px";
    this.elementLabel.style.paddingInline = "5px";
    this.elementLabel.style.borderRadius = "5px";
    this.elementLabel.style.background = this.style.background;
    this.elementLabel.style.boxShadow = this.style.boxShadow;
    this.elementLabel.style.color = this.style.color;
    this.elementLabel.style.font = this.style.font;
    this.elementLabel.style.transform = "translate(-50%, -50%)";
  }

  getDistance(): number {
    return this.startPoint.distanceTo(this.endPoint) / this.scale;
  }
}

let _widthHalf: number;
let _heightHalf: number;
const _viewMatrix = new Matrix4();
const _viewProjectionMatrix = new Matrix4();
const _vector = new Vector3();
const _vector1 = new Vector4();
const _vector2 = new Vector4();
const point = new Vector2();
const point1 = new Vector2();
const point2 = new Vector2();

class MeasureProjector {
  public camera: Camera;
  private canvas: HTMLElement;

  constructor(camera: Camera, canvas: HTMLCanvasElement) {
    this.camera = camera;
    this.canvas = canvas;
  }

  setFromCamera(camera: Camera) {
    this.camera = camera;
    this.updateProjectionMatrix();
  }

  updateProjectionMatrix() {
    const rect = this.canvas.getBoundingClientRect();
    _widthHalf = rect.width / 2;
    _heightHalf = rect.height / 2;

    _viewMatrix.copy(this.camera.matrixWorldInverse);
    _viewProjectionMatrix.multiplyMatrices(this.camera.projectionMatrix, _viewMatrix);
  }

  projectPoint(p: Vector3) {
    _vector.copy(p).applyMatrix4(_viewProjectionMatrix);
    const visible = _vector.z >= -1 && _vector.z <= 1;

    point.x = (_vector.x + 1) * _widthHalf;
    point.y = (-_vector.y + 1) * _heightHalf;

    return { point, visible };
  }

  projectLine(p1: Vector3, p2: Vector3) {
    let visible: boolean;

    _vector1.copy(p1 as any).applyMatrix4(_viewProjectionMatrix);
    _vector2.copy(p2 as any).applyMatrix4(_viewProjectionMatrix);

    // see three/examples/jsm/renderers/Projector.js/clipLine for more details

    const bc1near = _vector1.z + _vector1.w;
    const bc2near = _vector2.z + _vector2.w;
    const bc1far = -_vector1.z + _vector1.w;
    const bc2far = -_vector2.z + _vector2.w;

    if (bc1near >= 0 && bc2near >= 0 && bc1far >= 0 && bc2far >= 0) visible = true;
    else if ((bc1near < 0 && bc2near < 0) || (bc1far < 0 && bc2far < 0)) visible = false;
    else {
      let alpha1 = 0;
      let alpha2 = 1;

      if (bc1near < 0) alpha1 = Math.max(alpha1, bc1near / (bc1near - bc2near));
      else if (bc2near < 0) alpha2 = Math.min(alpha2, bc1near / (bc1near - bc2near));

      if (bc1far < 0) alpha1 = Math.max(alpha1, bc1far / (bc1far - bc2far));
      else if (bc2far < 0) alpha2 = Math.min(alpha2, bc1far / (bc1far - bc2far));

      visible = alpha2 >= alpha1;

      if (visible) {
        _vector1.lerp(_vector2, alpha1);
        _vector2.lerp(_vector1, 1 - alpha2);
      }
    }

    _vector1.multiplyScalar(1 / _vector1.w);
    _vector2.multiplyScalar(1 / _vector2.w);

    point1.x = (_vector1.x + 1) * _widthHalf;
    point1.y = (-_vector1.y + 1) * _heightHalf;

    point2.x = (_vector2.x + 1) * _widthHalf;
    point2.y = (-_vector2.y + 1) * _heightHalf;

    return { point1, point2, visible };
  }
}
