import type * as Mdast from 'mdast'
import type { IThemeRegistration, Lang, ILanguageRegistration } from 'shiki'

import { createHash } from 'crypto'

import { visit } from 'unist-util-visit'
import { toHtml } from 'hast-util-to-html'

import * as shiki from 'shiki'

// @ts-ignore
import { escape } from 'html-escaper'

import { unified } from 'unified'
import rehypeParse from 'rehype-parse'
import rangeParser from 'parse-numeric-range'
import { cx } from '$lib/twind'

// js title="..." showLineNumbers {1-3,4} /needle/3-5
// diff-js title focus=1-3,5
//
// [(diff-)?lang]? [title|title="..."]? [showLineNumbers|line-numbers]? [highlightLines]* [highlightTerm]* [focusLines]*
// highlightLines: {1-3,5} or highlight=1-3,5
// highlightTerm: /needle/ or /needle/3-5 (Highlight only the third to fifth instances)
// focusLines: [1-3,5] or focus=1-3,5
// line-numbers or showLineNumber
// TODO: mark=2[16:26]
// TODO: link=2[16:26] https://github.com/code-hike/codehike
// TODO: copy diff include +/-
// TODO: replace style="..." with class?
// TODO: highlight inline code: https://rehype-pretty-code.netlify.app

// TODO: https://github.com/kevin940726/remark-codesandbox

interface ClassNames {
  figure: string
  figcaption: string
  toolbar: string
  lang: string
  copy: string
  pre: string
  code: string
  'inline-code': string
  line: string
  'line-highlight': string
  'line-focus': string
  'line-not-focus': string
  'line-inserted': string
  'line-removed': string
  'line-unchanged': string
  'term-highlight': string
}

export default function attacher({
  themes,
  langs,
  classNames,
  tokenMap,
}: {
  themes: { light: IThemeRegistration; dark: IThemeRegistration }
  classNames?: Partial<ClassNames>
  /**
   * A list of languages to load upfront.
   */
  langs?: ILanguageRegistration[]
  tokenMap?: Record<string, string>
}): import('unified').Transformer<Mdast.Root, Mdast.Root> {
  return async function transformer(tree) {
    const [lightHighligher, darkHighlighter] = await Promise.all([
      shiki.getHighlighter({
        theme: themes.light,
        langs: [...(langs || []), ...shiki.BUNDLED_LANGUAGES],
      }),
      shiki.getHighlighter({
        theme: themes.dark,
        langs: [...(langs || []), ...shiki.BUNDLED_LANGUAGES],
      }),
    ])

    const loadedLanguages = lightHighligher.getLoadedLanguages()
    const light = { colors: {}, ...lightHighligher.getTheme() }
    const dark = { colors: {}, ...darkHighlighter.getTheme() }

    const resolvedClassNames: ClassNames = {
      figure: cx`relative group text-([${light.fg}] dark:[${dark.fg}]) bg-([${light.bg}] dark:[${dark.bg}]) border-([${light.colors['editorRuler.foreground']}] dark:[${dark.colors['editorRuler.foreground']}]) hover:border-([${light.colors['tab.border']}] dark:[${dark.colors['tab.border']}]) rounded-md shadow [data-line-numbers]:[counterReset:line]`,
      figcaption: cx`px-4 py-2 text-([${light.colors['tab.activeForeground']}] dark:[${dark.colors['tab.activeForeground']}]) bg-([${light.colors['tab.activeBackground']}] dark:[${dark.colors['tab.activeBackground']}]) border-b border-b-([${light.colors['tab.border']}] dark:[${dark.colors['tab.border']}]) rounded-t-md`,
      toolbar: cx`flex mb-2 relative text-xs`,
      lang: cx`ml-4 px-3 py-1 text-([${light.colors['tab.unfocusedActiveForeground']}] dark:[${dark.colors['tab.unfocusedActiveForeground']}]) bg-([${light.colors['tab.inactiveBackground']}] dark:[${dark.colors['tab.inactiveBackground']}]) border-r border-b border-l border-([${light.colors['tab.border']}] dark:[${dark.colors['tab.border']}]) rounded-bl-md rounded-br-md shadow-sm uppercase`,
      copy: cx`absolute top-0 right-0 flex items-center justify-center w-8 h-7 border border-transparent rounded-md transition-all origin-bottom-left text-([${light.colors['tab.unfocusedActiveForeground']}] dark:[${dark.colors['tab.unfocusedActiveForeground']}]) group-hover:(bg-([${light.colors['tab.activeBackground']}]/75 dark:[${dark.colors['tab.activeBackground']}]/75) border-([${light.colors['tab.border']}] dark:[${dark.colors['tab.border']}]) shadow-sm scale-125) &&:hocus:(text-([${light.colors['button.foreground']}] dark:[${dark.colors['button.foreground']}]) bg-([${light.colors['button.hoverBackground']}] dark:[${dark.colors['button.hoverBackground']}]) border-([${light.colors['tab.unfocusedActiveBorder']}] dark:[${dark.colors['tab.unfocusedActiveBorder']}]) scale-125) &&:focus-visible:(border-([${light.colors['focusBorder']}] dark:[${dark.colors['focusBorder']}]) ring-2 ring-([${light.colors['focusBorder']}] dark:[${dark.colors['focusBorder']}]) outline-none) [data-clipboard-copy='success']:!text-([${light.colors['terminal.ansiBrightGreen']} dark:[${dark.colors['terminal.ansiBrightGreen']}]]) [data-clipboard-copy='error']:!text-([${light.colors['terminal.ansiBrightRed']}] dark:[${dark.colors['terminal.ansiBrightRed']}])`,
      pre: cx`m-0 p-0 pb-2 text-([${light.colors['editor.foreground']}] dark:[${dark.colors['editor.foreground']}]) bg-([${light.colors['editor.background']}] dark:[${dark.colors['editor.background']}]) border-none rounded-t-none`,
      code: cx`grid`,
      'inline-code': cx`font-normal text-([${light.colors['editor.foreground']}] dark:[${dark.colors['editor.foreground']}]) bg-([${light.colors['editor.background']}] dark:[${dark.colors['editor.background']}]) dark:(mx-1 px-1 ring-1 ring-[${dark.colors['editor.background']}]/100 rounded-sm before:content-[''] after:content-[''])`,
      // TODO: with-line-numbers as own class
      line: cx`~(px-4 border-l-2 border-l-transparent empty:h-5 not-only-child:hover:bg-([${light.colors['editor.selectionBackground']}] dark:[${dark.colors['editor.selectionBackground']}]) [data-line-numbers]_&:not-only-child:hover:before:text-([${light.colors['editorLineNumber.activeForeground']}] dark:[${dark.colors['editorLineNumber.activeForeground']}]) [data-line-numbers]_&:pl-2 [data-line-numbers]_&:before:(inline-block w-4 mr-4 text-right text-([${light.colors['editorLineNumber.foreground']}] dark:[${dark.colors['editorLineNumber.foreground']}]) content-[counter(line)] counter-increment[line]))`,
      'line-highlight': cx`bg-([${light.colors['editor.lineHighlightBackground']}] dark:[${dark.colors['editor.lineHighlightBackground']}]) border-l-([${light.colors['focusBorder']}] dark:[${dark.colors['focusBorder']}]) [data-line-numbers]_&:before:text-([${light.colors['editorLineNumber.activeForeground']}] dark:[${dark.colors['editorLineNumber.activeForeground']}])`,
      'line-focus': cx`opacity-100 [data-line-numbers]_&:before:text-([${light.colors['editorLineNumber.activeForeground']}] dark:[${dark.colors['editorLineNumber.activeForeground']}])`,
      'line-not-focus': cx`opacity-50`,
      'line-inserted': cx`pl-2 bg-([${light.colors['diffEditor.insertedTextBackground']}] dark:[${dark.colors['diffEditor.insertedTextBackground']}]) before:(inline-block w-2 mr-1 content-['+'])`,
      'line-removed': cx`pl-2 bg-([${light.colors['diffEditor.removedTextBackground']}] dark:[${dark.colors['diffEditor.removedTextBackground']}]) before:(inline-block w-2 mr-1 content-['-'])`,
      'line-unchanged': cx`pl-2 opacity-50 before:(inline-block w-2 mr-1 content-['&nbsp;'])`,
      'term-highlight': cx`bg-([${light.colors['editor.selectionBackground']}] dark:[${dark.colors['editor.selectionBackground']}]) rounded-sm ring-2 ring-([${light.colors['editor.selectionBackground']}]/100 dark:[${dark.colors['editor.selectionBackground']}]/100)`,
      ...classNames,
    }

    const resolvedTokenMap: Record<string, string | undefined> = {
      import: 'constant.other.symbol',
      module: 'constant.other.symbol',
      package: 'entity.name.module.js',
      fn: 'entity.name.function',
      function: 'entity.name.function',
      arg: 'variable.parameter',
      param: 'variable.parameter',
      parameter: 'variable.parameter',
      let: 'support.other.variable',
      const: 'support.other.variable',
      var: 'support.other.variable',
      variable: 'support.other.variable',
      nil: 'constant.language.undefined',
      undefined: 'constant.language.undefined',
      null: 'constant.language.null',
      ...tokenMap,
    }

    function highlight(
      code: string,
      lang: string | undefined,
      lineOptions?: { line: number; classes: string[] }[],
    ) {
      if (lang && !loadedLanguages.includes(lang as Lang)) {
        console.warn(`Unrecognised language: ${lang}`)
        lang = undefined
      }

      const darkCode = unified()
        .use(rehypeParse, { fragment: true })
        .parse(darkHighlighter.codeToHtml(code, { lang: lang as Lang, lineOptions }))

      const darkClasses: string[][] = []
      visit(darkCode, 'element', (node) => {
        if (node.properties) {
          darkClasses.push(styleToClassNames((node.properties.style as string) || ''))
        }
      })

      const lightCode = unified()
        .use(rehypeParse, { fragment: true })
        .parse(lightHighligher.codeToHtml(code, { lang: lang as Lang, lineOptions }))

      visit(lightCode, 'element', (node) => {
        if (node.properties) {
          const darkClassNames = darkClasses.shift() as string[]
          const lightClassNames = styleToClassNames((node.properties.style as string) || '')

          const classNames = mergeClassNames(
            lightClassNames,
            darkClassNames,
            node.properties.className as string[],
          )

          node.properties.style = undefined
          node.properties.className = classNames.length ? classNames : undefined
        }
      })

      return lightCode
    }

    visit(tree, 'inlineCode', function visitor(node) {
      const { value } = node
      if (!value) {
        return
      }

      // TODO: allow escape characters to break out of highlighting
      let meta = ''
      const code = value.replace(/{:([a-z.-]+)}$/i, (_, $1) => {
        meta = $1
        return ''
      })

      const isLang = !meta.startsWith('.')

      let html: any

      if (isLang) {
        html = (highlight(code, meta).children[0] as any).children[0]
      } else {
        const lightToken = light.settings.find(
          ({ scope }) => meta && scope?.includes(resolvedTokenMap[meta.slice(1)] || meta.slice(1)),
        )?.settings

        const darkToken = dark.settings.find(
          ({ scope }) => meta && scope?.includes(resolvedTokenMap[meta.slice(1)] || meta.slice(1)),
        )?.settings

        const lightClassNames = [
          `text-[${lightToken?.foreground || light.fg}]`,
          lightToken?.background && `bg-[${lightToken.background}]`,
          // "bold underline italic"
          /\bbold\b/.test(lightToken?.fontStyle as string) && `font-bold`,
          /\bunderline\b/.test(lightToken?.fontStyle as string) && `underline`,
          /\bitalic\b/.test(lightToken?.fontStyle as string) && `italic`,
        ].filter(Boolean) as string[]

        const darkClassNames = [
          `text-[${darkToken?.foreground || dark.fg}]`,
          darkToken?.background && `bg-[${darkToken.background}]`,
          // "bold underline italic"
          /\bbold\b/.test(darkToken?.fontStyle as string) && `font-bold`,
          /\bunderline\b/.test(darkToken?.fontStyle as string) && `underline`,
          /\bitalic\b/.test(darkToken?.fontStyle as string) && `italic`,
        ].filter(Boolean) as string[]

        const classNames = mergeClassNames(lightClassNames, darkClassNames)

        html = unified()
          .use(rehypeParse, { fragment: true })
          .parse(
            `<code><span class="line"><span class="${classNames.join(' ')}">${escape(
              code,
            )}</span></span>`,
          )
      }

      visit(html, 'element', function (node) {
        if (node.tagName === 'code') {
          node.properties = {
            ...node.properties,
            'data-inline-code': true,
            className: resolvedClassNames['inline-code'].split(' '),
          }
          if (meta && isLang) {
            node.properties['data-lang'] = meta
          }

          node.children = node.children[0].children
        }
      })
      ;(node as any).type = 'html'
      node.value = toHtml(html)
    })

    visit(tree, 'code', function visitor(node) {
      const id =
        ':code:' +
        createHash('sha1')
          .update(JSON.stringify(node))
          .digest()
          .toString('hex')
          .replace(/[=/]/g, '')
          .slice(0, 8)

      const { code, lang, title, isDiff, showLineNumbers, terms, lineOptions } = parse(
        node,
        resolvedClassNames,
      )

      const html = highlight(code, lang, lineOptions)

      if (terms.length) {
        highlightTerms(html, terms, resolvedClassNames['term-highlight'] || 'term-highlight')
      }

      visit(html, 'element', function (node) {
        if (node.tagName === 'pre') {
          node.properties = {
            ...node.properties,
            className: (resolvedClassNames['pre'] || '').split(' '),
          }
        } else if (node.tagName === 'code') {
          node.properties = {
            ...node.properties,
            id,
            className: (resolvedClassNames['code'] || '').split(' '),
          }
        } else if (
          node.tagName === 'span' &&
          (node.properties?.className as undefined | string[])?.includes('line')
        ) {
          const classNames = new Set([
            ...(node.properties?.className as string[]).filter((className) => className !== 'line'),
            ...(resolvedClassNames['line'] || 'line').split(' '),
          ])

          node.properties = {
            ...node.properties,
            className: [...classNames],
          }
        }
      })

      const toolbar = [
        lang && `<span class="${resolvedClassNames['lang']}">${escape(lang)}</span>`,
        `<button data-clipboard-copy for="${id}" class="${resolvedClassNames['copy']}" aria-label="Copy code to clipboard">
          <svg xmlns="http://www.w3.org/2000/svg" class="hidden [data-clipboard-copy='']_&:block h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
            <path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
            <path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z" />
          </svg>

          <span class="hidden [data-clipboard-copy='success']_&:block" aria-label="success">
            <span aria-hidden="true">✓</span>
          </span>

          <span class="hidden [data-clipboard-copy='error']_&:block" aria-label="failed">
            <span aria-hidden="true">✗</span>
          </span>
        </button>`,
      ]
        .filter(Boolean)
        .join('')

      const attributes = [
        'data-code',
        lang && `data-lang="${escape(lang)}"`,
        isDiff && 'data-diff',
        showLineNumbers && 'data-line-numbers',
        `class="${resolvedClassNames['figure']}"`,
      ]
        .filter(Boolean)
        .join(' ')

      const body = [
        title &&
          `<figcaption class="${resolvedClassNames['figcaption']}">${escape(title)}</figcaption>`,
        `<div class="${resolvedClassNames['toolbar']}" data-pagefind-ignore="all">${toolbar}</div>`,
        toHtml(html),
      ]
        .filter(Boolean)
        .join('')

      ;(node as any).type = 'html'
      node.value = `<figure ${attributes}>${body}</figure>`
    })
  }
}

function parse(node: Mdast.Code, classNames: ClassNames) {
  let { meta, lang, value: code } = node

  meta = (meta || '').trim()
  const lines = code.split('\n')
  /** @type {Map<number, Set<string>>} */
  const lineOptions = new Map()

  /**
   * @param {number} line
   * @param {string} className
   */
  const addLineClass = (line: number, className: keyof ClassNames) => {
    let lineClassNames = lineOptions.get(line)

    if (!lineClassNames) {
      lineOptions.set(line, (lineClassNames = new Set()))
    }

    lineClassNames.add(classNames[className])
  }

  // [(diff-)?lang]? [title|title="..."]? [showLineNumbers|line-numbers]? [highlightLines]* [highlightTerm]* [focusLines]*
  // title=asas or title="..." or title='...' or title=`...`
  let title: string | undefined
  meta = meta.replace(/ *title=(?:([^"'`]\S+)|(["'`])(.+?)\2) */, (_, unqoted, quote, quoted) => {
    title = unqoted || quoted

    // remove the match
    return ''
  })

  // showLineNumber or line-numbers
  let showLineNumbers = false
  meta = meta.replace(/ *(showLineNumbers|line-numbers) */, () => {
    showLineNumbers = true

    // remove the match
    return ''
  })

  // highlightLines: {1-3,5} or highlight=1-3,5
  meta = meta.replace(/ *(?:{([\d.,-]+)}|highlight=([\d.,-]+)) */g, (_, unkeyed, keyed) => {
    rangeParser(unkeyed || keyed).map((line) => addLineClass(line, 'line-highlight'))

    // remove the match
    return ''
  })

  // focusLines: [1-3,5] or focus=1-3,5
  meta = meta.replace(/ *(?:\[([\d.,-]+)\]|focus=([\d.,-]+)) */, (_, unkeyed, keyed) => {
    const lineNumbers = rangeParser(unkeyed || keyed)
    lineNumbers.forEach((line) => addLineClass(line, 'line-focus'))
    Array.from({ length: lines.length }, (_, index) => index + 1)
      .filter((line) => !lineNumbers.includes(line))
      .map((line) => addLineClass(line, 'line-not-focus'))

    // remove the match
    return ''
  })

  const terms: { value: string; instances: number[] | null | undefined; count: number }[] = []

  // highlightTerm: /needle/ or /needle/3-5 (Highlight only the third to fifth instances)
  meta = meta.replace(
    / *\/((?:\\\/|'[^']+'|"[^"]+"|`[^`]+`|[^/])+)\/([\d.,-]+)? */g,
    (_, value, instances) => {
      terms.push({
        value: value.replace(/\\\//g, '/'),
        instances: instances && rangeParser(instances),
        count: 0,
      })

      // remove the match
      return ''
    },
  )

  // the remaining may be the title
  if (!title && meta) {
    title = meta
  }

  // lang=diff-js -> replace `+` and `-` by classes
  const isDiff = lang?.startsWith('diff-')
  if (isDiff) {
    lang = lang!.slice(5)

    code = lines
      .map((line, index) => {
        addLineClass(
          index + 1,
          line[0] == '+' ? 'line-inserted' : line[0] == '-' ? 'line-removed' : 'line-unchanged',
        )
        return line.slice(1)
      })
      .join('\n')
  }

  return {
    title,
    code,
    lang: lang || undefined,
    isDiff,
    showLineNumbers,
    terms,
    lineOptions: Array.from(lineOptions, ([line, classes]) => ({
      line,
      classes: [...classes].filter(Boolean),
    })).sort((a, b) => a.line - b.line),
  }
}

function highlightTerms(html: any, terms: any, className: string) {
  const cloneNode = (node: any, value: string, highlight = false) => {
    const classNames = highlight
      ? [...new Set([...(node.properties?.className || []), ...className.split(' ')])].filter(
          Boolean,
        )
      : node.properties?.className

    return {
      ...node,
      properties: { ...node.properties, className: classNames },
      children: [{ ...node.children[0], value, position: undefined }],
      position: undefined,
    }
  }

  visit(html, 'element', function (node) {
    if (
      node.tagName === 'span' &&
      (node.properties?.className as undefined | string | string[])?.includes('line')
    ) {
      // [ <span>...<span>, <span>...</span>, ...]

      for (const term of terms) {
        const needle = term.value
        let lineContent = ''
        for (let i = 0; i < node.children.length; i++) {
          const span = node.children[i]
          const textContent = span.children[0].value as string

          if (!textContent) {
            lineContent = ''
            continue
          }

          lineContent += textContent

          // 1. includes: prefix[needle]suffix
          const index = textContent.indexOf(needle)
          if (~index) {
            // <span>prefix.needle.suffix</span>
            // => <span>prefix.</span><span class="term-highlight">needle</span><span>.suffix</span>
            term.count += 1
            if (!term.instances || term.instances.includes(term.count)) {
              const prefix = textContent.slice(0, index)
              const suffix = textContent.slice(index + needle.length)
              // console.log({ textContent, needle, prefix, suffix })
              const newNodes = [
                prefix && cloneNode(span, prefix),
                cloneNode(span, needle, true),
                suffix && cloneNode(span, suffix),
              ].filter(Boolean)

              node.children.splice(i, 1, ...newNodes)
              if (prefix) {
                i += 1
              }
              lineContent = ''
            }
          } else {
            // <span>prefix.nee</span><span>dl</span><span>e.suffix</span>
            // => <span>prefix.</span><span class="term-highlight"><span>nee</span><span>dl</span><span>e</span></span><span>.suffix</span>
            // walk backwords through the nodes
            // prefix.nee|dl|e.suffix
            //
            const startIndex = lineContent.indexOf(needle)
            if (~startIndex) {
              term.count += 1

              if (!term.instances || term.instances.includes(term.count)) {
                let position = lineContent.length
                const endIndex = startIndex + needle.length

                let suffixNode
                const wrappedNodes = []
                for (let j = i; j >= 0; j--) {
                  const span = node.children[j]
                  const textContent = span.children[0].value as string

                  position -= textContent.length
                  const index =
                    position <= startIndex
                      ? Math.max(position, startIndex)
                      : Math.min(lineContent.length, endIndex)

                  const prefix = lineContent.slice(position, index)
                  const suffix = lineContent.slice(index)
                  lineContent = lineContent.slice(0, position)

                  if (j === i) {
                    // last node
                    suffixNode = suffix && cloneNode(span, suffix)
                  }

                  if (position <= startIndex) {
                    // first node
                    if (suffix) {
                      wrappedNodes.unshift(cloneNode(span, suffix))
                    }
                  } else if (prefix) {
                    wrappedNodes.unshift(cloneNode(span, prefix))
                  }

                  if (position <= startIndex) {
                    const newNodes = [
                      prefix && cloneNode(span, prefix),
                      {
                        type: 'element',
                        tagName: 'span',
                        properties: { className: className.split(' ') },
                        children: wrappedNodes,
                        position: undefined,
                      },
                      suffixNode,
                    ].filter(Boolean)

                    node.children.splice(j, i - j + 1, ...newNodes)
                    i = j + newNodes.length - 1
                    break
                  }
                }
              }

              lineContent = ''
            }
          }
        }
      }
    }
  })
}

function styleToClassNames(style: string): string[] {
  const classNames: string[] = []

  for (const [, property, value] of style.matchAll(/\s*([a-z-]+):\s*([^;]+)\s*;?\s*/g)) {
    switch (property) {
      case 'background-color': {
        classNames.push(`bg-[${value.toLowerCase()}]`)
        break
      }
      case 'color': {
        classNames.push(`text-[${value.toLowerCase()}]`)
        break
      }
      case 'font-weight': {
        classNames.push(`font-${value}`)
        break
      }
      case 'font-style':
      case 'text-decoration': {
        classNames.push(value)
        break
      }
      default: {
        console.warn(`Could not convert "${property}:${value}" to class name.`)
        classNames.push(`[${property}:${value}]`)
      }
    }
  }

  return classNames
}

function mergeClassNames(
  lightClassNames: string[],
  darkClassNames: string[],
  initialClassNames?: string[],
): string[] {
  const classNames = new Set<string>(initialClassNames || [])

  // reset non color styles for dark mode
  if (lightClassNames.includes('font-bold') && !darkClassNames.includes('font-bold')) {
    darkClassNames.push('font-normal')
  }
  if (lightClassNames.includes('italic') && !darkClassNames.includes('italic')) {
    darkClassNames.push('not-italic')
  }
  if (lightClassNames.includes('underline') && !darkClassNames.includes('underline')) {
    darkClassNames.push('no-underline')
  }

  lightClassNames.forEach((className) => classNames.add(className))
  darkClassNames.forEach((className) => classNames.add(`dark:${className}`))

  return [...classNames]
}
