// Platform info + the host resize event. The resize listener registers with the host lazily on the
// first subscription (no top-level side effect, so tree-shaking can drop this entirely if unused).

import { Quat } from "../math/quat"
import { Vec3 } from "../math/vec"
import { appEventsOn, appEventsOff } from "./appEvents"

type ResizeCallback = (width: number, height: number) => void

type DeviceEventMap = {
  resize: ResizeCallback
  /** Connectivity came back (best-effort, navigator.onLine semantics). */
  online: () => void
  /** Connectivity was lost. */
  offline: () => void
}

const resizeListeners: ResizeCallback[] = []
let resizeRegistered = false

const onResize = (width: number, height: number): void => {
  for (const cb of resizeListeners.slice()) cb(width, height)
}

/** Semantic haptic styles for `device.vibrate`. Impact styles (`light`/`medium`/`heavy`/`soft`/
 *  `rigid`) are a physical "tap" of varying weight; notification styles (`success`/`warning`/`error`)
 *  cue an outcome; `selection` is a light tick for a value change. Chosen to map 1:1 onto iOS
 *  `UIFeedbackGenerator` and, on Android, `HapticFeedbackConstants` / `VibrationEffect` — so the same
 *  call feels native everywhere, rather than a duration that only web/Android can honor. */
export type HapticStyle =
  | "light" | "medium" | "heavy" | "soft" | "rigid"
  | "success" | "warning" | "error"
  | "selection"

/** Options for `device.motion.start`. */
export interface MotionOptions {
  /** Sensor update interval in seconds (default 1/60). The sensor fuses at ≥ this rate in the
   *  background; you poll the freshest sample each frame, so this is a floor, not a sync. */
  interval?: number
  /** Frame the readables are delivered in. "world" (default): engine Y-up, screen-oriented — drops
   *  straight into `camera.quaternion` / any node. "device": the raw sensor frame, no conversion. */
  frame?: "world" | "device"
}

// --- device.motion state (pull-per-frame model) ---
let motionEnabled = false
let motionFrame: "world" | "device" = "world"
let motionYawOffset = 0   // radians about world up, captured by recenter()

// Calibration constants — VERIFY ON A PHYSICAL DEVICE (the simulator reports no motion).
//  • CM_TO_WORLD: Core Motion's reference frame is Z-up; the engine world is Y-up. If a level horizon
//    reads tilted ~90°, this is the term to check.
//  • ORIENTATION_ROLL: roll about the view axis so camera-up tracks screen-up, indexed by the native
//    interface-orientation code [portrait, landscapeLeft, landscapeRight, upsideDown]. If landscape is
//    upside-down or rolled the wrong way, flip the ±π/2 signs.
// If the whole panorama turns the wrong way (look right → view goes left), conjugate `raw` below.
const CM_TO_WORLD = Quat.fromAxisAngle([1, 0, 0], -Math.PI / 2)
const ORIENTATION_ROLL = [0, Math.PI / 2, -Math.PI / 2, Math.PI]

// Device attitude → engine world frame, with the interface-orientation roll folded in (yaw NOT yet
// recentered). Shared by `attitude` (which adds the recenter) and `gravity` (which is yaw-invariant).
const orientedAttitude = (s: ArrayLike<number>): Quat => {
  const raw = new Quat(s[0], s[1], s[2], s[3])
  const roll = Quat.fromAxisAngle([0, 0, 1], ORIENTATION_ROLL[s[7]] ?? 0)
  return CM_TO_WORLD.mul(raw).mul(roll)
}

export const device = {
  get platform(): "web" | "android" | "ios" | string {
    return _creatorUtils.platform
  },
  get language(): string {
    return _creatorUtils.language
  },
  /** Display device-pixel ratio (physical px per logical/CSS px): 1 on standard displays, 2–3 on
   *  retina / iOS. Bake a Canvas at this ratio for crisp output without hardcoding, e.g.
   *  `new Canvas(w, h, { pixelRatio: device.pixelRatio })`. NOTE: the web 2D/GL surface currently
   *  renders at logical resolution, so on web this only helps UI canvases / is forward-looking for
   *  2D; on iOS (physical surface) it makes 2D canvases crisp. Falls back to 1 if the host omits it. */
  get pixelRatio(): number {
    return _creatorUtils.pixelRatio || 1
  },
  /** Current display width in logical px — the same value the "resize" event delivers, but readable at
   *  any time (not only inside the listener). 0 until the host has reported a size. */
  get width(): number {
    return _creatorUI.getDisplaySize?.()?.[0] ?? 0
  },
  /** Current display height in logical px — the same value the "resize" event delivers, readable at any
   *  time. 0 until the host has reported a size. */
  get height(): number {
    return _creatorUI.getDisplaySize?.()?.[1] ?? 0
  },
  /** Opt into the precise-touch system where the platform supports it. OFF by default. When on, fast
   *  strokes are sampled at the touch digitizer's full rate (iOS coalesced touches, ≈120–240 Hz) instead
   *  of once per display frame (~60 Hz), so a pointer-heavy app (drawing, handwriting, dragging) gets more
   *  points and smoother lines. Leave it off for tap/button UIs. No-op on hosts without a coalesced-input
   *  concept (web already coalesces pointermove; headless). */
  setPreciseTouch(enabled: boolean): void {
    _creatorUtils.setPreciseTouch?.(enabled)
  },
  /** Fire a one-shot haptic of the given semantic `style` (default `"medium"`) — a physical tap on
   *  supported hardware (iOS Taptic Engine, Android vibrator). Chosen by meaning, not duration, so it
   *  feels native on each platform; see {@link HapticStyle}. Host-gated: a silent no-op where there's
   *  no haptic hardware (iPad, older iPhones, web, headless). */
  vibrate(style: HapticStyle = "medium"): void {
    _creatorUtils.vibrate?.(style)
  },
  /** Device-orientation sensor (gyro + accelerometer, fused) for tilt/steering and magic-window /
   *  360° panoramas. Poll `attitude` / `gravity` inside setLoop; they return the freshest fused
   *  sample, so the sensor rate need not match your frame rate. Host-gated: a no-op with no sensor. */
  motion: {
    /** Whether this device has the motion sensors at all (no gyro → false; iPad/older, web, headless). */
    get available(): boolean {
      return _creatorUtils.motionAvailable?.() ?? false
    },
    /** Whether updates are currently running (start succeeded and stop hasn't been called). */
    get enabled(): boolean {
      return motionEnabled
    },
    /** Begin sensor updates. Resolves to whether it actually started (false = no sensor / denied).
     *  Async so a web host can await its permission prompt; native resolves immediately. */
    async start(options: MotionOptions = {}): Promise<boolean> {
      motionFrame = options.frame ?? "world"
      motionEnabled = !!(await (_creatorUtils.motionStart?.(options.interval ?? 1 / 60) ?? false))
      return motionEnabled
    },
    /** Stop sensor updates and release the sensor (battery). */
    stop(): void {
      _creatorUtils.motionStop?.()
      motionEnabled = false
    },
    /** Capture the current heading as "forward" — a yaw-only recenter (pitch/roll stay gravity-
     *  referenced, so the horizon stays level). No-op in the "device" frame. */
    recenter(): void {
      const s = _creatorUtils.getMotionSample?.()
      if (!s || motionFrame === "device") { motionYawOffset = 0; return }
      const f = orientedAttitude(s).rotateVec3([0, 0, -1])   // world-space camera forward
      motionYawOffset = Math.atan2(-f.x, -f.z)               // heading about world up
    },
    /** The device's current orientation as a `Quat`. In the "world" frame (default) it's engine Y-up
     *  and screen-oriented, so `camera.quaternion = device.motion.attitude` is a complete magic-window
     *  / panorama camera. `Quat.identity` until the first sample arrives / when not running. */
    get attitude(): Quat {
      const s = _creatorUtils.getMotionSample?.()
      if (!s) return Quat.identity
      if (motionFrame === "device") return new Quat(s[0], s[1], s[2], s[3])
      const q = orientedAttitude(s)
      return Quat.fromAxisAngle([0, 1, 0], -motionYawOffset).mul(q)   // yaw recenter (world-frame)
    },
    /** Gravity direction for tilt controls. In the "world" frame it's SCREEN space (x → right, y →
     *  down, matching clientX/clientY), orientation-aware — a 2D game reads `gravity.x / gravity.y`.
     *  `(0,0,0)` when not running. */
    get gravity(): Vec3 {
      const s = _creatorUtils.getMotionSample?.()
      if (!s) return new Vec3(0, 0, 0)
      if (motionFrame === "device") return new Vec3(s[4], s[5], s[6])
      // Re-express the raw device-frame gravity in screen space: undo the interface roll (about the
      // screen normal), then flip Y to screen-down. Uses the unambiguous CMDeviceMotion.gravity, so
      // it stays correct regardless of the attitude-quaternion convention above.
      const unroll = Quat.fromAxisAngle([0, 0, 1], -(ORIENTATION_ROLL[s[7]] ?? 0))
      const g = new Vec3(s[4], s[5], s[6]).rotate(unroll)
      return new Vec3(g.x, -g.y, g.z)
    },
  },
  /** Current connectivity — best-effort navigator.onLine semantics: `false` only when the platform
   *  is sure there's no network. `true` on hosts that don't track it. Change events: `"online"` /
   *  `"offline"`. */
  get online(): boolean {
    return _creatorUtils.isOnline?.() ?? true
  },
  addEventListener<E extends keyof DeviceEventMap>(channel: E, callback: DeviceEventMap[E]): void {
    if (channel === "online" || channel === "offline") {
      appEventsOn(channel, callback as () => void)
      return
    }
    if (channel !== "resize") return
    resizeListeners.push(callback as ResizeCallback)
    if (!resizeRegistered) {
      resizeRegistered = true
      _creatorUI.registerResizeEvent(onResize)
    }
  },
  removeEventListener<E extends keyof DeviceEventMap>(channel: E, callback: DeviceEventMap[E]): void {
    if (channel === "online" || channel === "offline") {
      appEventsOff(channel, callback as () => void)
      return
    }
    if (channel !== "resize") return
    const i = resizeListeners.indexOf(callback as ResizeCallback)
    if (i >= 0) resizeListeners.splice(i, 1)
  },
}
