main.tstypescript
import { Quaternion } from "quaternion";
import { tryInjectMockContainer } from "./mock-tool";
import type { RotationListenerConfig, RotationUpdate } from "./types";
import { quaternionFromOrientationEvent } from "./utils/conversion";
import {
  permissionFunction,
  requestPermission,
} from "./utils/permission-handling";
import { applyQuaternionOffset, getOffsetQuaternion } from "./utils/rotation";

let rotationOffset: Quaternion | null = null;
let hasPermission = false;
let lastUpdate = 0;
let cb: (update: RotationUpdate) => void = () => {};
let cfg: RotationListenerConfig = {
  startOnInit: true,
  pollingRate: 60,
  onPermissionDenied: () => {},
  onPermissionGranted: () => {},
  onStart: () => {},
};

/**
 * Updates the global configuration for the rotation listener.
 *
 * It is recommended to call this before subscribing with {@link onUpdate}
 * or calling {@link start}.
 *
 * @param config - Partial configuration options to merge into the active configuration.
 *
 * @example
 * ```typescript
 * import { deviceRotation } from "web-orientation";
 *
 * deviceRotation.setConfig({
 *   pollingRate: 30, // Limit updates to 30 FPS
 *   startOnInit: false,
 *   onStart: () => console.log("Started tracking orientation"),
 *   onPermissionDenied: () => alert("Sensor permission denied"),
 * });
 * ```
 */
export const setConfig = (config: Partial<RotationListenerConfig>) => {
  cfg = { ...cfg, ...config };
};

/**
 * Registers a callback function to receive calibrated rotation updates.
 *
 * By default, this automatically initiates permission checks and begins listening
 * unless `startOnInit: false` was passed in {@link setConfig}.
 * Update frequency is throttled to `RotationListenerConfig.pollingRate` (default: 60 FPS).
 *
 * @param callback - Function invoked on each orientation tick with a {@link RotationUpdate}.
 *
 * @example
 * ```typescript
 * import { deviceRotation } from "web-orientation";
 *
 * deviceRotation.onUpdate((update) => {
 *   const { x, y, z, w } = update.quaternion;
 *   console.log(`Quaternion: x=${x}, y=${y}, z=${z}, w=${w}`);
 * });
 * ```
 */
export const onUpdate = (callback: (update: RotationUpdate) => void): void => {
  cb = callback;
  if (cfg.startOnInit) {
    getPermission().then(() => {
      beginListening();
    });
  }
};

const onOrientationEvent = (event: {
  beta: number | null;
  gamma: number | null;
  alpha: number | null;
}) => {
  const now = Date.now();
  // Throttle updates per second
  const fps = cfg.pollingRate;
  if (now - lastUpdate < 1000 / fps) {
    return;
  }

  lastUpdate = now;

  // Rotation values. beta = X, gamma = Y, alpha = Z
  const beta = event.beta;
  const gamma = event.gamma;
  const alpha = event.alpha;

  // If sensors are still initializing
  if (!beta || !gamma || !alpha) {
    return;
  }

  const currentRotation = quaternionFromOrientationEvent(beta, gamma, alpha);
  emitRotation(currentRotation);
};

export const emitRotation = (rotation: Quaternion) => {
  if (!rotationOffset) {
    // Calculate the rotation offset we need to apply in order
    // to get the model rotated to the 0 position
    rotationOffset = getOffsetQuaternion(rotation);
  }

  const finalRotation = applyQuaternionOffset(rotation, rotationOffset);

  const update: RotationUpdate = {
    quaternion: finalRotation,
  };

  cb(update);
};

const beginListening = async () => {
  tryInjectMockContainer();

  if ((await permissionFunction?.()) === "denied") {
    console.warn(
      "Orientation permission was not given before orientation listening started",
    );
  }

  if (!(window as any).Gyroscope) {
    console.warn(
      "Browser does not support Generic Sensor API, falling back to using legacy orientation sensor API.",
    );
    //TODO: Add sensor api fallback
  }

  window.addEventListener("deviceorientation", onOrientationEvent);
  cfg.onStart();
};

const getPermission = async () => {
  const permissionFunction = cfg.customPermissionFunction ?? requestPermission;

  let tries = 0;

  if (hasPermission === true) {
    cfg.onPermissionGranted();
    return;
  }

  while (hasPermission === false && tries < 5) {
    hasPermission = await permissionFunction();
    if (!hasPermission) {
      console.error(
        "Device rejected device orientation request, trying again...",
      );
      cfg.onPermissionDenied();
      tries += 1;
    } else {
      cfg.onPermissionGranted();
      tries = 0;
    }
  }
};

/**
 * Manually starts listening for orientation updates.
 *
 * Requests sensor permissions if not yet granted, and attaches device orientation listeners.
 * Note: Browsers require a secure context (`HTTPS`) for device sensor APIs.
 *
 * @example
 * ```typescript
 * import { deviceRotation } from "web-orientation";
 *
 * // When startOnInit is set to false:
 * document.getElementById("start-btn").addEventListener("click", () => {
 *   deviceRotation.start();
 * });
 * ```
 */
export const start = () => {
  if (!window.isSecureContext) {
    console.warn(
      "Page is not being served in a secure context (HTTPS)." +
        "Orientation will not work on some devices/browsers",
    );
  }

  getPermission().then(() => {
    beginListening();
  });
};

/**
 * Stops listening for device orientation updates and detaches event listeners.
 *
 * Use this when navigating away from a view or unmounting components to save battery and resources.
 *
 * @example
 * ```typescript
 * import { deviceRotation } from "web-orientation";
 *
 * onDestroy(() => {
 *   deviceRotation.stop();
 * });
 * ```
 */
export const stop = () => {
  window.removeEventListener("deviceorientation", onOrientationEvent);
  lastUpdate = 0;
};

/**
 * Resets and calibrates the reference frame, setting the current physical orientation as origin `(0, 0, 0, 1)`.
 *
 * Emits an immediate identity quaternion update through the registered {@link onUpdate} callback.
 * Subsequent updates are rotated relative to this new zero reference.
 *
 * @example
 * ```typescript
 * import { deviceRotation } from "web-orientation";
 *
 * document.getElementById("recalibrate-btn").addEventListener("click", () => {
 *   deviceRotation.resetOrientation();
 * });
 * ```
 */
export const resetOrientation = () => {
  rotationOffset = null;
  // Zero Quaternion
  cb({ quaternion: new Quaternion(1, 0, 0, 0) });
};