import * as React from 'react'
import * as OGL from 'ogl'
import { RESERVED_PROPS, INSTANCE_PROPS, POINTER_EVENTS } from './constants'
import { useIsomorphicLayoutEffect } from './hooks'
import { DPR, EventHandlers, Instance, Interactive, RootState } from './types'

/**
 * Converts camelCase primitives to PascalCase.
 */
export const toPascalCase = (str: string) => str.charAt(0).toUpperCase() + str.substring(1)

/**
 * Checks for inheritance between two classes.
 */
export const classExtends = (a: any, b: any) => (Object.prototype.isPrototypeOf.call(a, b) as boolean) || a === b

/**
 * Interpolates DPR from [min, max] based on device capabilities.
 */
export const calculateDpr = (dpr: DPR) =>
  Array.isArray(dpr) ? Math.min(Math.max(dpr[0], window.devicePixelRatio), dpr[1]) : dpr

/**
 * Resolves a potentially pierced key type against an object.
 */
export function resolve(root: any, key: string) {
  let target = root[key]
  if (!key.includes('-')) return { root, key, target }

  // Resolve pierced target
  const chain = key.split('-')
  target = chain.reduce((acc, key) => acc[key], root)
  key = chain.pop()!

  // Switch root if atomic
  if (!target?.set) root = chain.reduce((acc, key) => acc[key], root)

  return { root, key, target }
}

// Checks if a dash-cased string ends with an integer
const INDEX_REGEX = /-\d+$/

/**
 * Attaches an instance to a parent via its `attach` prop.
 */
export function attach(parent: Instance, child: Instance) {
  if (typeof child.props.attach === 'string') {
    // If attaching into an array (foo-0), create one
    if (INDEX_REGEX.test(child.props.attach)) {
      const target = child.props.attach.replace(INDEX_REGEX, '')
      const { root, key } = resolve(parent.object, target)
      if (!Array.isArray(root[key])) root[key] = []
    }

    const { root, key } = resolve(parent.object, child.props.attach)
    child.object.__previousAttach = root[key]
    root[key] = child.object
    child.object.__currentAttach = parent.object.__currentAttach = root[key]
  } else if (typeof child.props.attach === 'function') {
    child.object.__previousAttach = child.props.attach(parent.object, child.object)
  }
}

/**
 * Removes an instance from a parent via its `attach` prop.
 */
export function detach(parent: Instance, child: Instance) {
  if (typeof child.props.attach === 'string') {
    // Reset parent key if last attached
    if (parent.object.__currentAttach === child.object.__currentAttach) {
      const { root, key } = resolve(parent.object, child.props.attach)
      root[key] = child.object.__previousAttach
    }
  } else {
    child.object.__previousAttach(parent.object, child.object)
  }

  delete child.object.__previousAttach
  delete child.object.__currentAttach
  delete parent.object.__currentAttach
}

/**
 * Safely mutates an OGL element, respecting special JSX syntax.
 */
export function applyProps(object: any, newProps: any, oldProps?: any) {
  // Mutate our OGL element
  for (const prop in newProps) {
    // Don't mutate reserved keys
    if (RESERVED_PROPS.includes(prop as typeof RESERVED_PROPS[number])) continue
    if (INSTANCE_PROPS.includes(prop as typeof INSTANCE_PROPS[number])) continue

    // Don't mutate unchanged keys
    if (newProps[prop] === oldProps?.[prop]) continue

    // Collect event handlers
    const isHandler = POINTER_EVENTS.includes(prop as typeof POINTER_EVENTS[number])
    if (isHandler) {
      object.__handlers = { ...object.__handlers, [prop]: newProps[prop] }
      continue
    }

    const value = newProps[prop]
    const { root, key, target } = resolve(object, prop)

    // Prefer to use properties' copy and set methods
    // otherwise, mutate the property directly
    const isMathClass = typeof target?.set === 'function' && typeof target?.copy === 'function'
    if (!ArrayBuffer.isView(value) && isMathClass) {
      if (target.constructor === value.constructor) {
        target.copy(value)
      } else if (Array.isArray(value)) {
        target.set(...value)
      } else {
        // Support shorthand scalar syntax like scale={1}
        const scalar = new Array(target.length).fill(value)
        target.set(...scalar)
      }
    } else {
      // Allow shorthand values for uniforms
      const uniformList = value as any
      if (key === 'uniforms') {
        for (const uniform in uniformList) {
          // @ts-ignore
          let uniformValue = uniformList[uniform]?.value ?? uniformList[uniform]

          // Handle uniforms shorthand
          if (typeof uniformValue === 'string') {
            // Uniform is a string, convert it into a color
            uniformValue = new OGL.Color(uniformValue)
          } else if (
            uniformValue?.constructor === Array &&
            (uniformValue as any[]).every((v: any) => typeof v === 'number')
          ) {
            // @ts-ignore Uniform is an array, convert it into a vector
            uniformValue = new OGL[`Vec${uniformValue.length}`](...uniformValue)
          }

          root.uniforms[uniform] = { value: uniformValue }
        }
      } else {
        // Mutate the property directly
        root[key] = value
      }
    }
  }
}

/**
 * Creates event handlers, returning an event handler method.
 */
export function createEvents(state: RootState) {
  const handleEvent = (event: PointerEvent, type: keyof EventHandlers) => {
    // Convert mouse coordinates
    state.mouse!.x = (event.offsetX / state.size.width) * 2 - 1
    state.mouse!.y = -(event.offsetY / state.size.height) * 2 + 1

    // Filter to interactive meshes
    const interactive: Interactive[] = []
    state.scene.traverse((node: OGL.Transform | Interactive) => {
      // Mesh has registered events and a defined volume
      if (node instanceof OGL.Mesh && node.__handlers && node.geometry?.attributes?.position) interactive.push(node)
    })

    // Get elements that intersect with our pointer
    state.raycaster!.castMouse(state.camera, state.mouse)
    const intersects: Interactive[] = state.raycaster!.intersectMeshes(interactive)

    // Used to discern between generic events and custom hover events.
    // We hijack the pointermove event to handle hover state
    const isHoverEvent = type === 'onPointerMove'

    // Trigger events for hovered elements
    for (const object of intersects) {
      const handlers = object.__handlers

      // Bail if object doesn't have handlers (managed externally)
      if (!handlers) continue

      if (isHoverEvent && !state.hovered!.get(object.id)) {
        // Mark object as hovered and fire its hover events
        state.hovered!.set(object.id, object)

        // Fire hover events
        handlers.onPointerMove?.({ ...object.hit, nativeEvent: event })
        handlers.onPointerOver?.({ ...object.hit, nativeEvent: event })
      } else {
        // Otherwise, fire its generic event
        handlers[type]?.({ ...object.hit, nativeEvent: event })
      }
    }

    // Cleanup stale hover events
    if (isHoverEvent || type === 'onPointerDown') {
      state.hovered!.forEach((object) => {
        const handlers = object.__handlers

        if (!intersects.length || !intersects.find((i) => i === object)) {
          // Reset hover state
          state.hovered!.delete(object.id)

          // Fire unhover event
          if (handlers?.onPointerOut) handlers.onPointerOut({ ...object.hit, nativeEvent: event })
        }
      })
    }

    return intersects
  }

  return { handleEvent }
}

export type SetBlock = false | Promise<null> | null

/**
 * Used to block rendering via its `set` prop. Useful for suspenseful effects.
 */
export function Block({ set }: { set: React.Dispatch<React.SetStateAction<SetBlock>> }) {
  useIsomorphicLayoutEffect(() => {
    set(new Promise(() => null))
    return () => set(false)
  }, [])

  return null
}

/**
 * Generic error boundary. Calls its `set` prop on error.
 */
export class ErrorBoundary extends React.Component<
  { set: React.Dispatch<any>; children: React.ReactNode },
  { error: boolean }
> {
  state = { error: false }
  static getDerivedStateFromError = () => ({ error: true })
  componentDidCatch(error: any) {
    this.props.set(error)
  }
  render() {
    return this.state.error ? null : this.props.children
  }
}
