import { warn } from './internal/warn'
import type { Sheet, SheetRule } from './types'
import { asArray, noop } from './utils'

function getStyleElement(selector: string | null | undefined | false): HTMLStyleElement {
  let style = document.querySelector(selector || 'style[data-twind]')

  if (!style || style.tagName != 'STYLE') {
    style = document.createElement('style')
    ;(style as HTMLElement).dataset.twind = ''
    document.head.prepend(style)
  }

  return style as HTMLStyleElement
}

/**
 * @group Sheets
 * @param element
 * @returns
 */
export function cssom(
  element?: CSSStyleSheet | HTMLStyleElement | string | null | false,
): Sheet<CSSStyleSheet> {
  const target = (element as CSSStyleSheet)?.cssRules
    ? (element as CSSStyleSheet)
    : ((element && typeof element != 'string'
        ? (element as HTMLStyleElement)
        : getStyleElement(element)
      ).sheet as CSSStyleSheet)

  return {
    target,

    snapshot() {
      // collect current rules
      const rules = Array.from(target.cssRules, (rule) => rule.cssText)

      return () => {
        // remove all existing rules
        this.clear()

        // add all snapshot rules back
        // eslint-disable-next-line @typescript-eslint/unbound-method
        rules.forEach(this.insert as (cssText: string, index: number) => void)
      }
    },

    clear() {
      // remove all added rules
      for (let index = target.cssRules.length; index--; ) {
        target.deleteRule(index)
      }
    },

    destroy() {
      target.ownerNode?.remove()
    },

    insert(cssText, index) {
      try {
        // Insert
        target.insertRule(cssText, index)
      } catch (error) {
        // Empty rule to keep index valid — not using `*{}` as that would show up in all rules (DX)
        target.insertRule(':root{}', index)

        // Some thrown errors are because of specific pseudo classes
        // lets filter them to prevent unnecessary warnings
        // ::-moz-focus-inner
        // :-moz-focusring
        if (!/:-[mwo]/.test(cssText)) {
          warn((error as Error).message, 'TWIND_INVALID_CSS', cssText)
        }
      }
    },

    resume: noop,
  }
}

/**
 * @group Sheets
 * @param element
 * @returns
 */
export function dom(element?: HTMLStyleElement | string | null | false): Sheet<HTMLStyleElement> {
  const target = element && typeof element != 'string' ? element : getStyleElement(element)

  return {
    target,

    snapshot() {
      // collect current rules
      const rules = Array.from(target.childNodes, (node) => node.textContent as string)

      return () => {
        // remove all existing rules
        this.clear()

        // add all snapshot rules back
        // eslint-disable-next-line @typescript-eslint/unbound-method
        rules.forEach(this.insert as (cssText: string, index: number) => void)
      }
    },

    clear() {
      target.textContent = ''
    },

    destroy() {
      target.remove()
    },

    insert(cssText, index) {
      target.insertBefore(document.createTextNode(cssText), target.childNodes[index] || null)
    },

    resume: noop,
  }
}

/**
 * @group Sheets
 * @param includeResumeData
 * @returns
 */
export function virtual(includeResumeData?: boolean): Sheet<string[]> {
  const target: string[] = []

  return {
    target,

    snapshot() {
      // collect current rules
      const rules = [...target]

      return () => {
        // remove all existing rules and add all snapshot rules back
        target.splice(0, target.length, ...rules)
      }
    },

    clear() {
      target.length = 0
    },

    destroy() {
      this.clear()
    },

    insert(css, index, rule) {
      target.splice(
        index,
        0,
        includeResumeData
          ? `/*!${rule.p.toString(36)},${(rule.o * 2).toString(36)}${
              rule.n ? ',' + rule.n : ''
            }*/${css}`
          : css,
      )
    },

    resume: noop,
  }
}

/**
 * Returns a sheet useable in the current environment.
 *
 * @group Sheets
 * @param useDOMSheet usually something like `process.env.NODE_ENV != 'production'` or `import.meta.env.DEV` (default: browser={@link cssom}, server={@link virtual})
 * @param disableResume to not include or use resume data
 * @returns a sheet to use
 */
export function getSheet(
  useDOMSheet?: boolean,
  disableResume?: boolean,
): Sheet<string[] | HTMLStyleElement | CSSStyleSheet> {
  const sheet =
    typeof document == 'undefined' ? virtual(!disableResume) : useDOMSheet ? dom() : cssom()

  if (!disableResume) sheet.resume = resume

  return sheet
}

/**
 * @group Sheets
 * @param target
 * @returns
 */
export function stringify(target: unknown): string {
  // string[] | CSSStyleSheet | HTMLStyleElement
  return (
    // prefer the raw text content of a CSSStyleSheet as it may include the resume data
    ((target as CSSStyleSheet).ownerNode || (target as HTMLStyleElement)).textContent ||
    ((target as CSSStyleSheet).cssRules
      ? Array.from((target as CSSStyleSheet).cssRules, (rule) => rule.cssText)
      : asArray(target)
    ).join('')
  )
}

function resume(
  this: Sheet,
  addClassName: (className: string) => void,
  insert: (cssText: string, rule: SheetRule) => void,
) {
  // hydration from SSR sheet
  const textContent = stringify(this.target)
  const RE = /\/\*!([\da-z]+),([\da-z]+)(?:,(.+?))?\*\//g

  // only if this is a hydratable sheet
  if (RE.test(textContent)) {
    // RE has global flag — reset index to get the first match as well
    RE.lastIndex = 0

    // 1. start with a fresh sheet
    this.clear()

    // 2. add all existing class attributes to the token/className cache
    if (typeof document != 'undefined') {
      for (const el of document.querySelectorAll('[class]')) {
        addClassName(el.getAttribute('class') as string)
      }
    }

    // 3. parse SSR styles
    let lastMatch: RegExpExecArray | null | undefined

    while (
      (function commit(match?: RegExpExecArray | null) {
        if (lastMatch) {
          insert(
            // grep the cssText from the previous match end up to this match start
            textContent.slice(lastMatch.index + lastMatch[0].length, match?.index),
            {
              p: parseInt(lastMatch[1], 36),
              o: parseInt(lastMatch[2], 36) / 2,
              n: lastMatch[3],
            },
          )
        }

        return (lastMatch = match)
      })(RE.exec(textContent))
    ) {
      /* no-op */
    }
  }
}
