// 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 HdrChangeCallback = (headroom: number) => void

type DeviceEventMap = {
  resize: ResizeCallback
  /** Connectivity came back (best-effort, navigator.onLine semantics). */
  online: () => void
  /** Connectivity was lost. */
  offline: () => void
  /** The headroom the 3D engine renders to moved (`device.hdr.headroom`): the display ramped up to
   *  its peak after launch, brightness changed, the window went to another screen. */
  hdrchange: HdrChangeCallback
}

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

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

// --- device.hdr state ---
// The 3D bridge is a free global that a 2D-only bundle never defines — reach it defensively. Hosts
// without the methods are SDR by definition; the look setters are remembered so the getters read
// back what the app asked for.
const gl = (): typeof _creator | undefined => (typeof _creator !== "undefined" ? _creator : undefined)
let hdrStrengthLocal = 0.35
let hdrPaperWhiteLocal = 1
// `hdrchange` is a poll: the engine quantises the headroom to half-stops, so a change is rare and
// a 4 Hz timer (only while someone listens) is cheaper than a per-frame bridge call.
const hdrListeners: HdrChangeCallback[] = []
let hdrPollTimer: ReturnType<typeof setInterval> | undefined
let hdrLastHeadroom = 1
const pollHdr = (): void => {
  const h = device.hdr.headroom
  if (h === hdrLastHeadroom) return
  hdrLastHeadroom = h
  for (const cb of hdrListeners.slice()) cb(h)
}

/** 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 = {
  /** Which INPUT MODEL the app is running under — the thing to branch on when a build needs
   *  mouse-look instead of on-screen sticks. It is not the OS name: a Mac reports `"desktop"`,
   *  like Windows and Linux do (the OS/version detail is host-side, not here). */
  get platform(): "web" | "android" | "ios" | "desktop" | 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)
    },
  },
  /** The display's extended dynamic range — HDR. On a screen with headroom above SDR white (Apple's
   *  XDR panels; the macOS host today) the 3D surface is float and the engine renders past 1.0:
   *  1.0 is the white of the UI and of a diffuse white surface, and the sun, emissives and speculars
   *  climb up to `headroom` times that. Everywhere else it is SDR: `available` false, `headroom` 1,
   *  the look setters remembered but invisible.
   *
   *  Branch content on `available`, not on `headroom` — the live value ramps up from 1 over the
   *  first seconds after launch and follows the brightness keys. Particle `emissive` is the thing
   *  to raise: it is in post-exposure units (1 = white on screen) and everything above that clips on
   *  SDR, so an HDR display is the only place a fireball peaking at 8 reads as one. */
  hdr: {
    /** Whether the surface can show anything above SDR white at all (`maxHeadroom > 1`). */
    get available(): boolean {
      return device.hdr.maxHeadroom > 1
    },
    /** The peak the display can reach, as a multiple of SDR white (a 1600-nit XDR panel reports up
     *  to 16). Constant for the surface; 1 on SDR. */
    get maxHeadroom(): number {
      const v = gl()?.getDisplayMaxHeadroom?.() ?? 1
      return v > 1 ? v : 1
    },
    /** The headroom the engine renders to right now — the screen's current peak as a multiple of SDR
     *  white, quantised to half-stops. Moves with brightness and the screen under the window
     *  (`hdrchange` event); never below 1. */
    get headroom(): number {
      const v = gl()?.getDisplayHeadroom?.() ?? 1
      return v > 1 ? v : 1
    },
    /** How much of the picture reaches for the headroom, 0..1 (default 0.35). 0 touches only what
     *  SDR clipped — faithful to the SDR grade, but a mostly-mid-tone frame then looks flat next to
     *  the one bright spot; 1 lifts nearly everything above black toward the peak. A sunlit exterior
     *  takes 1, a dim interior wants less. A host may pin it (`LECODES_HDR_STRENGTH`). */
    get strength(): number {
      return gl()?.getHdrStrength?.() ?? hdrStrengthLocal
    },
    set strength(v: number) {
      const s = Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0.35
      hdrStrengthLocal = s
      gl()?.setHdrStrength?.(s)
    },
    /** Where white lands, as a multiple of SDR white, 1..8 (default 1) — the "HDR brightness" of a
     *  console game's calibration screen, where paper white sits at ~200 nits against SDR's ~100.
     *  It scales the WHOLE picture: at 1 a white wall is as bright as the UI's white, at 2 it is
     *  twice that, and highlights still climb above it (the engine keeps `headroom / paperWhite` for
     *  them, clamped at the peak). `strength` decides how much of the picture reaches for the
     *  peak; this decides where the picture starts. 1.5–2 is the console norm; a game that wants to
     *  read as "HDR on" rather than "SDR with a brighter sun" wants this over `strength`. A host may
     *  pin it (`LECODES_HDR_PAPER_WHITE`). */
    get paperWhite(): number {
      return gl()?.getHdrPaperWhite?.() ?? hdrPaperWhiteLocal
    },
    set paperWhite(v: number) {
      const p = Number.isFinite(v) ? Math.min(8, Math.max(1, v)) : 1
      hdrPaperWhiteLocal = p
      gl()?.setHdrPaperWhite?.(p)
    },
  },
  /** 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 === "hdrchange") {
      hdrListeners.push(callback as HdrChangeCallback)
      if (hdrPollTimer === undefined) {
        hdrLastHeadroom = device.hdr.headroom
        hdrPollTimer = setInterval(pollHdr, 250)
      }
      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 === "hdrchange") {
      const i = hdrListeners.indexOf(callback as HdrChangeCallback)
      if (i >= 0) hdrListeners.splice(i, 1)
      if (hdrListeners.length === 0 && hdrPollTimer !== undefined) {
        clearInterval(hdrPollTimer)
        hdrPollTimer = undefined
      }
      return
    }
    if (channel !== "resize") return
    const i = resizeListeners.indexOf(callback as ResizeCallback)
    if (i >= 0) resizeListeners.splice(i, 1)
  },
}
