import {
  Component,
  createContext,
  createRenderEffect,
  createUniqueId,
  JSX,
  onCleanup,
  ParentComponent,
  sharedConfig,
  useContext,
} from 'solid-js'
import { isServer, spread, escape as escapeMeta, useAssets, ssr } from 'solid-js/web'

export const MetaContext = createContext<MetaContextType>()

interface TagDescription {
  tag: string
  props: Record<string, unknown>
  setting?: { close?: boolean; escape?: boolean }
  id: string
  name?: string
  ref?: Element
}

export interface MetaContextType {
  addTag: (tag: TagDescription) => number
  removeTag: (tag: TagDescription, index: number) => void
}

const cascadingTags = ['title', 'meta']

// https://html.spec.whatwg.org/multipage/semantics.html#the-title-element
const titleTagProperties: string[] = []

const metaTagProperties: string[] =
  // https://html.spec.whatwg.org/multipage/semantics.html#the-meta-element
  ['name', 'http-equiv', 'content', 'charset', 'media']
    // additional properties
    .concat(['property'])

const getTagKey = (tag: TagDescription, properties: string[]) => {
  // pick allowed properties and sort them
  const tagProps = Object.fromEntries(
    Object.entries(tag.props)
      .filter(([k]) => properties.includes(k))
      .sort(),
  )

  // treat `property` as `name` for meta tags
  if (Object.hasOwn(tagProps, 'name') || Object.hasOwn(tagProps, 'property')) {
    tagProps.name = tagProps.name || tagProps.property
    tagProps.property = undefined
  }

  // concat tag name and properties as unique key for this tag
  return tag.tag + JSON.stringify(tagProps)
}

function initClientProvider() {
  if (!sharedConfig.context) {
    const ssrTags = document.head.querySelectorAll('[data-sm]')
    // `forEach` on `NodeList` is not supported in Googlebot, so use a workaround
    Array.prototype.forEach.call(ssrTags, (ssrTag: Node) => ssrTag.parentNode?.removeChild(ssrTag))
  }

  const cascadedTagInstances = new Map()
  // TODO: use one element for all tags of the same type, just swap out
  // where the props get applied
  function getElement(tag: TagDescription) {
    if (tag.ref) {
      return tag.ref
    }
    let el = document.querySelector(`[data-sm="${tag.id}"]`)
    if (el) {
      if (el.tagName.toLowerCase() !== tag.tag) {
        if (el.parentNode) {
          // remove the old tag
          el.parentNode.removeChild(el)
        }
        // add the new tag
        el = document.createElement(tag.tag)
      }
      // use the old tag
      el.removeAttribute('data-sm')
    } else {
      // create a new tag
      el = document.createElement(tag.tag)
    }
    return el
  }

  return {
    addTag(tag: TagDescription) {
      if (cascadingTags.indexOf(tag.tag) !== -1) {
        const properties = tag.tag === 'title' ? titleTagProperties : metaTagProperties
        const tagKey = getTagKey(tag, properties)

        //  only cascading tags need to be kept as singletons
        if (!cascadedTagInstances.has(tagKey)) {
          cascadedTagInstances.set(tagKey, [])
        }

        let instances = cascadedTagInstances.get(tagKey)
        const index = instances.length

        instances = [...instances, tag]

        // track indices synchronously
        cascadedTagInstances.set(tagKey, instances)

        const element = getElement(tag)
        tag.ref = element

        spread(element, tag.props)

        let lastVisited = null
        for (let i = index - 1; i >= 0; i--) {
          if (instances[i] != null) {
            lastVisited = instances[i]
            break
          }
        }

        if (element.parentNode !== document.head) {
          document.head.appendChild(element)
        }
        if (lastVisited?.ref?.parentNode) {
          document.head?.removeChild(lastVisited.ref)
        }

        return index
      }

      const element = getElement(tag)
      tag.ref = element

      spread(element, tag.props)

      if (element.parentNode !== document.head) {
        document.head.appendChild(element)
      }

      return -1
    },
    removeTag(tag: TagDescription, index: number) {
      const properties = tag.tag === 'title' ? titleTagProperties : metaTagProperties
      const tagKey = getTagKey(tag, properties)

      if (tag.ref) {
        const t = cascadedTagInstances.get(tagKey)
        if (t) {
          if (tag.ref.parentNode) {
            tag.ref.parentNode.removeChild(tag.ref)
            for (let i = index - 1; i >= 0; i--) {
              if (t[i] != null) {
                document.head.appendChild(t[i].ref)
              }
            }
          }

          t[index] = null
          cascadedTagInstances.set(tagKey, t)
        } else if (tag.ref.parentNode) {
          tag.ref.parentNode.removeChild(tag.ref)
        }
      }
    },
  }
}

function initServerProvider() {
  const tags: TagDescription[] = []
  useAssets(() => ssr(renderTags(tags)) as string)

  return {
    addTag(tagDesc: TagDescription) {
      // tweak only cascading tags
      if (cascadingTags.indexOf(tagDesc.tag) !== -1) {
        const properties = tagDesc.tag === 'title' ? titleTagProperties : metaTagProperties
        const tagDescKey = getTagKey(tagDesc, properties)
        const index = tags.findIndex(
          (prev) => prev.tag === tagDesc.tag && getTagKey(prev, properties) === tagDescKey,
        )
        if (index !== -1) {
          tags.splice(index, 1)
        }
      }
      tags.push(tagDesc)
      return tags.length
    },
    // biome-ignore lint/suspicious/noEmptyBlockStatements: initial value
    removeTag(_tag: TagDescription, _index: number) {},
  }
}

export const MetaProvider: ParentComponent = (props) => {
  const actions = isServer ? initServerProvider() : initClientProvider()
  return <MetaContext.Provider value={actions || {}}>{props.children}</MetaContext.Provider>
}

const MetaTag = (
  tag: string,
  props: { [k: string]: string },
  setting?: { escape?: boolean; close?: boolean },
) => {
  useHead({
    tag,
    props,
    setting,
    id: createUniqueId(),
    get name() {
      return props.name || props.property
    },
  })

  return null
}

export function useHead(tagDesc: TagDescription) {
  const c = useContext(MetaContext)
  if (!c) throw new Error('<MetaProvider /> should be in the tree')

  createRenderEffect(() => {
    const index = c?.addTag(tagDesc)
    onCleanup(() => c?.removeTag(tagDesc, index))
  })
}

function renderTags(tags: TagDescription[]) {
  return tags
    .map((tag) => {
      const keys = Object.keys(tag.props)
      const props = keys
        .map((k) =>
          k === 'children'
            ? ''
            : ` ${k}="${
                // @ts-expect-error
                escapeMeta(tag.props[k], true)
              }"`,
        )
        .join('')
      const children = tag.props.children
      if (tag.setting?.close) {
        return `<${tag.tag} data-sm="${tag.id}"${props}>${
          // @ts-expect-error
          tag.setting?.escape ? escapeMeta(children) : children || ''
        }</${tag.tag}>`
      }
      return `<${tag.tag} data-sm="${tag.id}"${props}/>`
    })
    .join('')
}

export const Title: Component<JSX.HTMLAttributes<HTMLTitleElement>> = (props) =>
  MetaTag('title', props, { escape: true, close: true })

export const Style: Component<JSX.StyleHTMLAttributes<HTMLStyleElement>> = (props) =>
  MetaTag('style', props, { close: true })

export const Meta: Component<JSX.MetaHTMLAttributes<HTMLMetaElement>> = (props) => MetaTag('meta', props)

export const Link: Component<JSX.LinkHTMLAttributes<HTMLLinkElement>> = (props) => MetaTag('link', props)

export const Base: Component<JSX.BaseHTMLAttributes<HTMLBaseElement>> = (props) => MetaTag('base', props)

export const Stylesheet: Component<Omit<JSX.LinkHTMLAttributes<HTMLLinkElement>, 'rel'>> = (props) => (
  <Link rel="stylesheet" {...props} />
)
