import { ComponentType, h, render } from 'preact'
import { InitialProps, Island } from './island'

type HostElement = HTMLElement | ShadowRoot

export const isInShadow = (node: HostElement | HTMLOrSVGScriptElement) => {
  return node.getRootNode() instanceof ShadowRoot
}

export const isShadowRoot = (x: unknown): x is ShadowRoot => {
  return x instanceof ShadowRoot
}

export const formatProp = (str: string) => {
  return `${str.charAt(0).toLowerCase()}${str.slice(1)}`
}

export const getPropsFromElement = (
  element: HostElement | HTMLOrSVGScriptElement,
) => {
  // In a shadow dom we replace the host element because it's within the shadow root. However,
  // we want the props of the autonomous custom element.
  const targetElement = isInShadow(element)
    ? (element.getRootNode() as any).host
    : element

  const { dataset } = targetElement

  const props: { [x: string]: any } = {}

  for (var d in dataset) {
    // We don't pull props for inherited attributes
    if (dataset.hasOwnProperty(d) === false) return

    // data-prop or data-props works!
    const propName = formatProp(d.split(/(props?)/).pop() || '')

    if (propName) {
      props[propName] = dataset[d]
    }
  }

  return props
}

export const isValidPropsScript = (element: Element) => {
  return (
    // element.tagName.toLowerCase() === 'script' &&
    ['text/props', 'application/json'].includes(
      element.getAttribute('type') || '',
    )
  )
}

export const getInteriorPropsScriptsForElement = (element: HostElement) => {
  // getElementsByTagName does not exist on shadow roots and within a shadow root
  // the caller can't place in props scripts
  if (isShadowRoot(element)) return []

  return Array.from(element.getElementsByTagName('script')).filter(
    isValidPropsScript,
  )
}

export const getPropsScriptsBySelector = (selector: string) => {
  return Array.from(document.querySelectorAll(selector)).filter(
    isValidPropsScript,
    // Checked by filter call
  ) as HTMLOrSVGScriptElement[]
}

export const getPropsFromScripts = (scripts: HTMLOrSVGScriptElement[]) => {
  let interiorScriptProps: any = {}
  scripts.forEach((script) => {
    // Swallow any potential errors so we don't throw on someone else's page
    try {
      interiorScriptProps = {
        ...interiorScriptProps,
        ...JSON.parse(script.innerHTML),
      }
    } catch (e: any) {}
  })
  return interiorScriptProps
}

/**
 * Get the props from a host element's data attributes
 * @param  {Element} The host element
 * @return {Object}  props object to be passed to the component
 */
export const generateHostElementProps = <P extends InitialProps>(
  island: Island<P>,
  element: HostElement,
  initialProps = {},
  propsSelector: string | undefined | null,
): P => {
  const elementProps = getPropsFromElement(element)

  const currentScriptProps = island._executedScript
    ? getPropsFromElement(island._executedScript)
    : {}
  const interiorScriptProps = getPropsFromScripts(
    getInteriorPropsScriptsForElement(element),
  )

  const propsSelectorProps = propsSelector
    ? getPropsFromScripts(getPropsScriptsBySelector(propsSelector))
    : {}

  return {
    ...initialProps,
    ...elementProps,
    ...currentScriptProps,
    ...propsSelectorProps,
    ...interiorScriptProps,
  }
}

export const getHostElements = ({
  selector,
  inline,
  elementName,
}: {
  selector?: string
  inline: boolean
  /**
   * Passed if targeting web components so that mount in can create web components inside of the host elements
   */
  elementName?: string
}): HostElement[] => {
  const currentScript = document.currentScript

  if (inline && currentScript?.parentNode) {
    // @ts-ignore Not sure on this one
    return [currentScript.parentNode]
  }

  // Next, try to get the selector from the current script
  const maybeSelector = currentScript?.dataset.mountIn

  if (maybeSelector) {
    return Array.from(
      document.querySelectorAll<HTMLElement>(maybeSelector),
    ).map((n) => {
      if (elementName != null) {
        const targetElement = document.createElement(elementName)
        const node = n.appendChild(targetElement)
        return node.shadowRoot != null ? node.shadowRoot : node
      }

      return n
    })
  }

  if (selector) {
    return Array.from(document.querySelectorAll<HTMLElement>(selector)).map(
      (n) => (n.shadowRoot != null ? n.shadowRoot : n),
    )
  }

  return []
}

/**
 * A Preact 11+ implementation of the `replaceNode` parameter from Preact 10.
 *
 * This creates a "Persistent Fragment" (a fake DOM element) containing one or more
 * DOM nodes, which can then be passed as the `parent` argument to Preact's `render()` method.
 *
 * Lifted from: https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c
 */
export type RootFragment = any

export function createRootFragment(
  parent: HostElement,
  replaceNode: HostElement | HostElement[],
): RootFragment {
  replaceNode = ([] as HostElement[]).concat(replaceNode)
  var s = replaceNode[replaceNode.length - 1].nextSibling
  function insert(c: HTMLElement, r: HTMLElement) {
    parent.insertBefore(c, r || s)
  }
  // Mutating the parent to add a preact property
  // @ts-expect-error We're mutating the parent to add these properties for Preact
  return (parent.__k = {
    nodeType: 1,
    parentNode: parent,
    firstChild: replaceNode[0],
    childNodes: replaceNode,
    insertBefore: insert,
    appendChild: insert,
    removeChild: function (c: HTMLElement) {
      parent.removeChild(c)
    },
  })
}

export const watchForPropChanges = <P extends InitialProps>({
  island,
  hostElement,
  initialProps,
  onNewProps,
  propsSelector,
}: {
  island: Island<P>
  hostElement: HostElement
  initialProps: any
  onNewProps: (props: P) => void
  propsSelector: string | undefined | null
}) => {
  const observer = new MutationObserver(function (mutations) {
    mutations.forEach(function () {
      onNewProps(
        generateHostElementProps(
          island,
          hostElement,
          initialProps,
          propsSelector,
        ),
      )
    })
  })

  const config = { attributes: true, childList: true, characterData: true }

  if (island._executedScript) {
    observer.observe(island._executedScript, config)
  }

  getInteriorPropsScriptsForElement(hostElement).forEach((script) => {
    observer.observe(script, { ...config, subtree: true })
  })

  if (propsSelector) {
    getPropsScriptsBySelector(propsSelector).forEach((script) => {
      observer.observe(script, { ...config, subtree: true })
    })
  }

  /**
   * If the host element is a shadow root we want to observe on the host of it.
   *
   * Example:
   * <preact-element data-prop-foo="bar">
   *    #shadow-root (open)
   * </preact-element>
   *
   * We want to observe the custom autonomous element, not the shadow root!
   */
  observer.observe(
    isShadowRoot(hostElement) ? hostElement.host! : hostElement,
    config,
  )

  return observer
}

export const renderIsland = <P extends InitialProps>({
  island,
  widget,
  rootFragment,
  props,
}: {
  island: Island<P>
  widget: ComponentType<P>
  rootFragment: RootFragment
  props: P
}) => {
  island.props = props
  render(h(widget, props), rootFragment)
}

export const mount = <P extends InitialProps>({
  island,
  widget,
  hostElements,
  clean,
  replace,
  initialProps,
  propsSelector,
}: {
  island: Island<P>
  widget: ComponentType<P>
  hostElements: Array<HostElement>
  clean: boolean
  replace: boolean
  initialProps: P
  propsSelector?: string
}) => {
  const rootFragments: any = []

  hostElements.forEach((hostElement) => {
    const props = generateHostElementProps<P>(
      island,
      hostElement,
      initialProps,
      propsSelector,
    )
    if (clean) {
      hostElement.replaceChildren()
    }

    let rootFragment: any
    if (replace) {
      rootFragment = createRootFragment(
        hostElement.parentElement || document.body,
        hostElement,
      )
    } else {
      const renderNode = document.createElement('div')
      hostElement.appendChild(renderNode)
      rootFragment = createRootFragment(hostElement, renderNode)
    }

    rootFragments.push(rootFragment)

    renderIsland({ island, widget, rootFragment, props })

    const observer = watchForPropChanges<P>({
      island,
      hostElement,
      initialProps,
      onNewProps: (newProps) => {
        renderIsland({ island, widget, rootFragment, props: newProps })
      },
      propsSelector,
    })

    island._rootsToObservers.set(rootFragment, observer)
  })

  return { rootFragments }
}
