import { useEffect, useRef, useState, type ReactNode } from 'react'

import type { Viewer } from '@mkkellogg/gaussian-splats-3d'

export interface CameraSnapshot {
  position: [number, number, number]
  target: [number, number, number]
  up: [number, number, number]
}

export type SplatPreviewState = 'waiting' | 'loading' | 'ready' | 'failed'

export interface SplatPreviewStatus {
  state: SplatPreviewState
  message: string
}

interface SplatPreviewProps {
  plyUrl: string | null
  generationKey: number
  initialCameraPosition?: [number, number, number]
  initialCameraTarget?: [number, number, number]
  initialCameraUp?: [number, number, number]
  bgColor: string
  fov: number
  autoRotate: boolean
  maxScreenSize: number
  /** Use static-scene rendering when the displayed result will not be transformed live. */
  dynamicScene?: boolean
  splatPosition: [number, number, number]
  splatRotation: [number, number, number]
  splatFlip: [boolean, boolean, boolean]
  onCameraChange?: (snap: CameraSnapshot) => void
  onViewerStateChange?: (status: SplatPreviewStatus) => void
  /** Called only after this PLY URL no longer has an attached viewer. */
  onViewerDisposed?: (plyUrl: string) => void
  children?: ReactNode
}

export function SplatPreview({
  plyUrl,
  generationKey,
  initialCameraPosition,
  initialCameraTarget,
  initialCameraUp,
  bgColor,
  fov,
  autoRotate,
  maxScreenSize,
  dynamicScene = true,
  splatPosition,
  splatRotation,
  splatFlip,
  onCameraChange,
  onViewerStateChange,
  onViewerDisposed,
  children,
}: SplatPreviewProps) {
  const containerRef = useRef<HTMLDivElement | null>(null)
  const viewerRef = useRef<Viewer | null>(null)
  const [viewerStatus, setViewerStatus] = useState<SplatPreviewStatus>({
    state: 'waiting',
    message: 'Waiting for a generated splat…',
  })

  const onCameraChangeRef = useRef(onCameraChange)
  onCameraChangeRef.current = onCameraChange
  const onViewerStateChangeRef = useRef(onViewerStateChange)
  onViewerStateChangeRef.current = onViewerStateChange
  const onViewerDisposedRef = useRef(onViewerDisposed)
  onViewerDisposedRef.current = onViewerDisposed

  // Refs hold the latest transform values so the mount effect can apply them
  // when the viewer is recreated, without restarting on every transform change.
  const splatPositionRef = useRef(splatPosition)
  const splatRotationRef = useRef(splatRotation)
  const splatFlipRef = useRef(splatFlip)
  splatPositionRef.current = splatPosition
  splatRotationRef.current = splatRotation
  splatFlipRef.current = splatFlip

  useEffect(() => {
    let cancelled = false
    let localViewer: Viewer | null = null

    const publishStatus = (state: SplatPreviewState, message: string) => {
      if (cancelled) return
      const status = { state, message }
      setViewerStatus(status)
      onViewerStateChangeRef.current?.(status)
    }

    const mount = async () => {
      if (!plyUrl || !containerRef.current) {
        publishStatus('waiting', 'Waiting for a generated splat…')
        return
      }

      publishStatus('loading', 'Loading splat preview…')
      try {
        const module = await import('@mkkellogg/gaussian-splats-3d')
        if (cancelled || !containerRef.current) {
          return
        }

        const viewerOptions: Record<string, unknown> = {
          rootElement: containerRef.current,
          selfDrivenMode: true,
          useBuiltInControls: true,
          sharedMemoryForWorkers: false,
          ignoreDevicePixelRatio: false,
          inMemoryCompressionLevel: 0,
          maxScreenSpaceSplatSize: maxScreenSize,
          dynamicScene,
        }
        if (initialCameraPosition) viewerOptions.initialCameraPosition = initialCameraPosition
        if (initialCameraTarget) viewerOptions.initialCameraLookAt = initialCameraTarget
        if (initialCameraUp) viewerOptions.cameraUp = initialCameraUp

        localViewer = new module.Viewer(viewerOptions)
        viewerRef.current = localViewer
        localViewer.start()
        const initialPos = splatPositionRef.current
        const initialRot = splatRotationRef.current
        const initialFlip = splatFlipRef.current
        const sceneOptions: Record<string, unknown> = {
          format: module.SceneFormat.Ply,
          showLoadingUI: false,
          splatAlphaRemovalThreshold: 1,
        }
        if (initialPos.some((n) => n !== 0)) sceneOptions.position = initialPos
        if (initialRot.some((n) => n !== 0)) {
          sceneOptions.rotation = eulerDegToQuaternion(initialRot)
        }
        if (initialFlip.some(Boolean)) {
          sceneOptions.scale = flipsToScale(initialFlip)
        }

        await localViewer.addSplatScene(plyUrl, sceneOptions)
        if (cancelled) return

        applyLiveSettings(localViewer, { bgColor, fov, autoRotate })
        applySplatTransform(localViewer, {
          position: splatPositionRef.current,
          rotation: splatRotationRef.current,
          flip: splatFlipRef.current,
        })
        if (onCameraChangeRef.current) {
          attachCameraReporter(localViewer, () => onCameraChangeRef.current)
        }

        publishStatus('ready', 'Preview ready. Drag to orbit, scroll to zoom.')
      } catch (error) {
        if (!cancelled) {
          publishStatus(
            'failed',
            `Preview failed: ${error instanceof Error ? error.message : String(error)}`,
          )
        }
      }
    }

    void mount()

    return () => {
      cancelled = true
      const viewer = localViewer ?? viewerRef.current
      viewerRef.current = null
      const notifyDisposed = () => {
        if (plyUrl) onViewerDisposedRef.current?.(plyUrl)
      }
      if (viewer) {
        void Promise.resolve()
          .then(() => viewer.dispose())
          .catch(() => undefined)
          .finally(notifyDisposed)
      } else {
        notifyDisposed()
      }
    }
    // maxScreenSize and dynamicScene are constructor options; changing either
    // requires a viewer recreate.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [plyUrl, generationKey, maxScreenSize, dynamicScene])

  useEffect(() => {
    const viewer = viewerRef.current
    if (!viewer) return
    applyLiveSettings(viewer, { bgColor, fov, autoRotate })
  }, [bgColor, fov, autoRotate])

  useEffect(() => {
    const viewer = viewerRef.current
    if (!viewer) return
    applySplatTransform(viewer, {
      position: splatPosition,
      rotation: splatRotation,
      flip: splatFlip,
    })
  }, [splatPosition, splatRotation, splatFlip])

  return (
    <section className="panel preview-panel">
      <div className="preview-toolbar">
        <span className="preview-title">Gaussian Splat Preview</span>
        <span
          className="preview-status"
          data-testid="splat-preview-status"
          data-viewer-state={viewerStatus.state}
        >
          {viewerStatus.message}
        </span>
      </div>
      <div className="splat-canvas-shell">
        <div ref={containerRef} className="splat-canvas-host" data-testid="splat-canvas-host" />
        {!plyUrl ? <div className="splat-empty">Generate a splat to preview it here.</div> : null}
      </div>
      {children}
    </section>
  )
}

function applyLiveSettings(
  viewer: Viewer,
  settings: { bgColor: string; fov: number; autoRotate: boolean },
): void {
  const cam = (viewer as unknown as { camera?: { fov?: number; updateProjectionMatrix?: () => void } }).camera
  if (cam && typeof cam.fov === 'number' && Number.isFinite(settings.fov)) {
    cam.fov = settings.fov
    cam.updateProjectionMatrix?.()
  }
  const renderer = (viewer as unknown as { renderer?: { setClearColor?: (c: string) => void } }).renderer
  renderer?.setClearColor?.(settings.bgColor)

  const controls = (viewer as unknown as { controls?: { autoRotate?: boolean; autoRotateSpeed?: number } }).controls
  if (controls) {
    controls.autoRotate = settings.autoRotate
    if (settings.autoRotate) controls.autoRotateSpeed = 1.0
  }
}

interface SplatTransform {
  position: [number, number, number]
  rotation: [number, number, number]
  flip: [boolean, boolean, boolean]
}

interface Vec3Like {
  set(x: number, y: number, z: number): void
}

interface QuatLike {
  setFromEuler(euler: { x: number; y: number; z: number; order?: string }): void
}

interface SceneObject {
  position?: Vec3Like
  quaternion?: QuatLike
  scale?: Vec3Like
}

interface SplatMeshLike {
  scenes?: SceneObject[]
  getScene?(index: number): SceneObject | null | undefined
  updateTransforms?(): void
}

function applySplatTransform(viewer: Viewer, transform: SplatTransform): void {
  const splatMesh = (viewer as unknown as { splatMesh?: SplatMeshLike }).splatMesh
  if (!splatMesh) return
  const scene = splatMesh.getScene?.(0) ?? splatMesh.scenes?.[0]
  if (!scene) return

  scene.position?.set(transform.position[0], transform.position[1], transform.position[2])

  if (scene.quaternion) {
    const radX = (transform.rotation[0] * Math.PI) / 180
    const radY = (transform.rotation[1] * Math.PI) / 180
    const radZ = (transform.rotation[2] * Math.PI) / 180
    scene.quaternion.setFromEuler({ x: radX, y: radY, z: radZ, order: 'XYZ' } as never)
  }

  const sx = transform.flip[0] ? -1 : 1
  const sy = transform.flip[1] ? -1 : 1
  const sz = transform.flip[2] ? -1 : 1
  scene.scale?.set(sx, sy, sz)

  splatMesh.updateTransforms?.()
}

function eulerDegToQuaternion(rot: [number, number, number]): [number, number, number, number] {
  // ZYX order to match Three.js's default Euler (XYZ extrinsic == ZYX intrinsic).
  const cx = Math.cos((rot[0] * Math.PI) / 360)
  const sx = Math.sin((rot[0] * Math.PI) / 360)
  const cy = Math.cos((rot[1] * Math.PI) / 360)
  const sy = Math.sin((rot[1] * Math.PI) / 360)
  const cz = Math.cos((rot[2] * Math.PI) / 360)
  const sz = Math.sin((rot[2] * Math.PI) / 360)
  // XYZ Euler -> quaternion (Three.js convention)
  const x = sx * cy * cz + cx * sy * sz
  const y = cx * sy * cz - sx * cy * sz
  const z = cx * cy * sz + sx * sy * cz
  const w = cx * cy * cz - sx * sy * sz
  return [x, y, z, w]
}

function flipsToScale(flip: [boolean, boolean, boolean]): [number, number, number] {
  return [flip[0] ? -1 : 1, flip[1] ? -1 : 1, flip[2] ? -1 : 1]
}

function attachCameraReporter(viewer: Viewer, getCallback: () => ((snap: CameraSnapshot) => void) | undefined): void {
  const controls = (
    viewer as unknown as {
      controls?: {
        addEventListener?: (type: string, listener: () => void) => void
        target?: { x: number; y: number; z: number }
      }
    }
  ).controls
  const cam = (
    viewer as unknown as {
      camera?: {
        position?: { x: number; y: number; z: number }
        up?: { x: number; y: number; z: number }
      }
    }
  ).camera
  if (!controls?.addEventListener || !cam?.position || !cam?.up || !controls.target) return

  const emit = () => {
    const cb = getCallback()
    if (!cb) return
    cb({
      position: [cam.position!.x, cam.position!.y, cam.position!.z],
      target: [controls.target!.x, controls.target!.y, controls.target!.z],
      up: [cam.up!.x, cam.up!.y, cam.up!.z],
    })
  }
  controls.addEventListener('change', emit)
  emit()
}
