import Reconciler from 'react-reconciler'
import { DefaultEventPriority } from 'react-reconciler/constants'
import * as OGL from 'ogl'
import * as React from 'react'
import { toPascalCase, applyProps, attach, detach, classExtends } from './utils'
import { RESERVED_PROPS } from './constants'
import { Act, Catalogue, Fiber, Instance, InstanceProps, OGLElements } from './types'

// Custom objects that extend the OGL namespace
const catalogue = { ...OGL } as unknown as Catalogue

// Effectful catalogue elements that require a `WebGLRenderingContext`.
const catalogueGL: any[] = [
  // Core
  OGL.Camera,
  OGL.Geometry,
  OGL.Mesh,
  OGL.Program,
  OGL.RenderTarget,
  OGL.Texture,

  // Extras
  OGL.Flowmap,
  OGL.GPGPU,
  OGL.NormalProgram,
  OGL.Polyline,
  OGL.Post,
  OGL.Shadow,
  OGL.AxesHelper,
  OGL.GridHelper,
  OGL.WireMesh,
]

/**
 * Extends the OGL catalogue, accepting an object of keys pointing to external classes.
 * `gl` will flag `objects` to receive a `WebGLRenderingContext` on creation.
 */
export function extend(objects: Partial<Catalogue>, gl = false) {
  for (const key in objects) {
    const value = objects[key as keyof Catalogue]!
    catalogue[key as keyof Catalogue] = value
    if (gl) catalogueGL.push(value)
  }
}

/**
 * Creates an OGL element from a React node.
 */
function createInstance(type: keyof OGLElements, { object = null, args = [], ...props }: InstanceProps, root: Fiber) {
  // Convert lowercase primitive to PascalCase
  const name = toPascalCase(type) as keyof Catalogue

  // Get class from extended OGL catalogue
  const target = catalogue[name]

  // Validate OGL elements
  if (type !== 'primitive' && !target) throw `${type} is not a part of the OGL catalogue! Did you forget to extend?`

  // Validate primitives
  if (type === 'primitive' && !object) throw `"object" must be set when using primitives.`

  // Create instance
  const instance: Instance = {
    root,
    parent: null,
    children: [],
    type,
    props: { ...props, args },
    object,
  }

  return instance
}

/**
 * Adds elements to our scene and attaches children to their parents.
 */
const appendChild = (parent: Instance, child: Instance) => {
  child.parent = parent
  parent.children.push(child)
}

/**
 * Removes elements from scene and disposes of them.
 */
function removeChild(parent: Instance, child: Instance) {
  child.parent = null
  const childIndex = parent.children.indexOf(child)
  if (childIndex !== -1) parent.children.splice(childIndex, 1)

  if (child.props.attach) detach(parent, child)
  else if (child.object instanceof OGL.Transform) parent.object.removeChild(child.object)

  if (child.props.dispose !== null) child.object.dispose?.()
  child.object = null
}

function commitInstance(instance: Instance) {
  // Don't handle commit for containers
  if (!instance.parent) return

  if (instance.type !== 'primitive' && !instance.object) {
    const name = toPascalCase(instance.type) as keyof Catalogue
    const target = catalogue[name]
    const { args = [], ...props } = instance.props

    // Pass internal state to elements which depend on it.
    // This lets them be immutable upon creation and use props
    const isGLInstance = Object.values(catalogueGL).some((elem) => classExtends(elem, target))
    if (isGLInstance) {
      const { gl } = instance.root.getState()
      const filtered = args.filter((arg) => arg !== gl)

      // Accept props as args for programs & geometry
      if (instance.type === 'program' || instance.type === 'geometry') {
        const attrs = Object.entries(props).reduce((acc, [key, value]) => {
          // Don't include non-attributes for geometry
          if (instance.type === 'geometry' && !(value as OGL.Attribute)?.data) return acc
          // Include non-pierced props
          if (!key.includes('-')) acc[key] = value
          return acc
        }, filtered[0] ?? {})

        instance.object = new target(gl, attrs)
      } else {
        instance.object = new target(gl, ...filtered)
      }
    } else {
      instance.object = new target(...args)
    }
  }

  // Auto-attach geometry and programs to meshes
  if (!instance.props.attach) {
    if (instance.object instanceof OGL.Geometry) instance.props.attach = 'geometry'
    else if (instance.object instanceof OGL.Program) instance.props.attach = 'program'
  }

  // Append children
  for (const child of instance.children) {
    if (child.props.attach) attach(instance, child)
    else if (child.object instanceof OGL.Transform) child.object.setParent(instance.object)
  }

  // Append to container
  if (!instance.parent.parent) {
    if (instance.props.attach) attach(instance.parent, instance)
    else if (instance.object instanceof OGL.Transform) instance.object.setParent(instance.parent.object)
  }

  // Apply props to OGL object
  applyProps(instance.object, instance.props)
}

/**
 * Switches instance to a new one, moving over children.
 */
function switchInstance(instance: Instance, type: keyof OGLElements, props: InstanceProps, root: Fiber) {
  // Create a new instance
  const newInstance = createInstance(type, props, instance.root)

  // Replace instance in scene-graph
  const parent = instance.parent!
  removeChild(parent, instance)
  appendChild(parent, newInstance)

  // Commit new instance object
  commitInstance(newInstance)

  // Append to scene-graph
  if (parent.parent) {
    if (newInstance.props.attach) attach(parent, newInstance)
    else if (newInstance.object instanceof OGL.Transform) newInstance.object.setParent(parent.object)
  }

  // Move children to new instance
  for (const child of instance.children) {
    appendChild(newInstance, child)
    if (child.props.attach) {
      detach(instance, child)
      attach(newInstance, child)
    }
  }
  instance.children = []

  // Switches the react-internal fiber node
  // https://github.com/facebook/react/issues/14983
  ;[root, root.alternate].forEach((fiber) => {
    if (fiber !== null) {
      fiber.stateNode = newInstance
      if (fiber.ref) {
        if (typeof fiber.ref === 'function') (fiber as unknown as any).ref(newInstance.object)
        else (fiber.ref as Reconciler.RefObject).current = newInstance.object
      }
    }
  })

  return newInstance
}

/**
 * Shallow checks objects.
 */
function checkShallow(a: any, b: any) {
  // If comparing arrays, shallow compare
  if (Array.isArray(a)) {
    // Check if types match
    if (!Array.isArray(b)) return false

    // Shallow compare for match
    if (a == b) return true

    // Sort through keys
    if (a.every((v, i) => v === b[i])) return true
  }

  // Atomically compare
  if (a === b) return true

  return false
}

/**
 * Prepares a set of changes to be applied to the instance.
 */
function diffProps(instance: Instance, newProps: InstanceProps, oldProps: InstanceProps) {
  const changedProps: InstanceProps = {}

  // Sort through props
  for (const key in newProps) {
    // Skip reserved keys
    if (RESERVED_PROPS.includes(key as typeof RESERVED_PROPS[number])) continue
    // Skip primitives
    if (instance.type === 'primitive' && key === 'object') continue
    // Skip if props match
    if (checkShallow(newProps[key], oldProps[key])) continue

    // Props changed, add them
    changedProps[key] = newProps[key]
  }

  return changedProps
}

/**
 * Inserts an instance between instances of a ReactNode.
 */
function insertBefore(parent: Instance, child: Instance, beforeChild: Instance) {
  if (!child) return

  child.parent = parent
  parent.children.splice(parent.children.indexOf(beforeChild), 0, child)
}

/**
 * Centralizes and handles mutations through an OGL scene-graph.
 */
export const reconciler = Reconciler<
  // Instance type
  keyof OGLElements,
  // Instance props
  InstanceProps,
  // Fiber container
  Fiber,
  // Normal instance
  Instance,
  // Text instance
  never,
  // Suspense instance
  Instance,
  // Hydratable instance
  never,
  // Public (ref) instance
  Instance['object'],
  // Host context
  null,
  // applyProps diff sets
  null | [boolean] | [boolean, InstanceProps],
  // Hydration child set
  never,
  // Timeout id handle
  typeof setTimeout | undefined,
  // NoTimeout
  -1
>({
  // Configure renderer for tree-like mutation and interop w/react-dom
  isPrimaryRenderer: false,
  supportsMutation: true,
  supportsHydration: false,
  supportsPersistence: false,
  // Add SSR time fallbacks
  scheduleTimeout: () => (typeof setTimeout !== 'undefined' ? setTimeout : undefined),
  cancelTimeout: () => (typeof clearTimeout !== 'undefined' ? clearTimeout : undefined),
  noTimeout: -1,
  // Text isn't supported so we skip it
  shouldSetTextContent: () => false,
  resetTextContent: () => {},
  createTextInstance() {
    throw new Error('Text is not allowed in the OGL scene-graph!')
  },
  hideTextInstance() {
    throw new Error('Text is not allowed in the OGL scene-graph!')
  },
  unhideTextInstance: () => {},
  // Modifies the ref to return the instance object itself.
  getPublicInstance: (instance) => instance.object,
  // We can optionally access different host contexts on instance creation/update.
  // Instances' data structures are self-sufficient, so we don't make use of this
  getRootHostContext: () => null,
  getChildHostContext: (parentHostContext) => parentHostContext,
  // We can optionally mutate portal containers here, but we do that in createPortal instead from state
  preparePortalMount: (container) => container,
  // This lets us store stuff at the container-level before/after React mutates our OGL elements.
  // Elements are mutated in isolation, so we don't do anything here.
  prepareForCommit: () => null,
  resetAfterCommit: () => {},
  // This can modify the container and clear children.
  // Might be useful for disposing on demand later
  clearContainer: () => false,
  // This creates a OGL element from a React element
  createInstance,
  // These methods add elements to the scene
  appendChild,
  appendInitialChild: appendChild,
  appendChildToContainer: () => {},
  // These methods remove elements from the scene
  removeChild,
  removeChildFromContainer: () => {},
  // We can specify an order for children to be inserted here.
  // This is useful if you want to override stuff like materials
  insertBefore,
  insertInContainerBefore: () => {},
  // Used to calculate updates in the render phase or commitUpdate.
  // Greatly improves performance by reducing paint to rapid mutations.
  prepareUpdate(instance, type, oldProps, newProps) {
    // Element is a primitive. We must recreate it when its object prop is changed
    if (instance.type === 'primitive' && oldProps.object !== newProps.object) return [true]

    // Element is a program. Check whether its vertex or fragment props changed to recreate
    if (type === 'program') {
      if (oldProps.vertex !== newProps.vertex) return [true]
      if (oldProps.fragment !== newProps.fragment) return [true]
    }

    // Element is a geometry. Check whether its attribute props changed to recreate.
    if (type === 'geometry') {
      for (const key in oldProps) {
        const isAttribute = (oldProps[key] as OGL.Attribute)?.data || (newProps[key] as OGL.Attribute)?.data
        if (isAttribute && oldProps[key] !== newProps[key]) return [true]
      }
    }

    // If the instance has new args, recreate it
    if (newProps.args?.some((value, index) => value !== oldProps.args?.[index])) return [true]

    // Diff through props and flag with changes
    const changedProps = diffProps(instance, newProps, oldProps)
    if (Object.keys(changedProps).length) return [false, changedProps]

    // No changes, don't update the instance
    return null
  },
  // This is where we mutate OGL elements in the render phase
  commitUpdate(instance, payload, type, oldProps, newProps, root) {
    const [reconstruct, changedProps] = payload!

    // If flagged for recreation, swap to a new instance.
    if (reconstruct) return switchInstance(instance, type, newProps, root)

    // Handle attach update
    if (changedProps?.attach) {
      if (oldProps.attach) detach(instance.parent!, instance)
      instance.props.attach = newProps.attach
      if (newProps.attach) attach(instance.parent!, instance)
    }

    // Update instance props
    Object.assign(instance.props, newProps)

    // Apply changed props
    applyProps(instance.object, newProps)
  },
  // Methods to toggle instance visibility on demand.
  // React uses this with React.Suspense to display fallback content
  hideInstance(instance) {
    if (instance.object instanceof OGL.Transform) {
      instance.object.visible = false
    }
  },
  unhideInstance(instance) {
    if (instance.object instanceof OGL.Transform) {
      instance.object.visible = (instance.props.visible as boolean) ?? true
    }
  },
  // Configures a callback once finalized and instances are linked up to one another.
  // This is a safe time to create instances' respective OGL elements without worrying
  // about side-effects if react changes its mind and discards an instance (e.g. React.Suspense)
  finalizeInitialChildren: () => true,
  commitMount: commitInstance,
  // @ts-ignore
  getCurrentEventPriority: () => DefaultEventPriority,
  beforeActiveInstanceBlur: () => {},
  afterActiveInstanceBlur: () => {},
  detachDeletedInstance: () => {},
})

export const act: Act = (React as any).unstable_act

// Inject renderer meta into devtools
const isProd = typeof process === 'undefined' || process.env?.['NODE_ENV'] === 'production'
reconciler.injectIntoDevTools({
  findFiberByHostInstance: (instance) => instance.root,
  bundleType: isProd ? 0 : 1,
  version: React.version,
  rendererPackageName: 'react-ogl',
})
