'use client'

import {
  createContext,
  useContext,
  useSyncExternalStore,
  type ComponentProps,
  type ReactNode,
} from 'react'
import { useDeepCompareMemo } from '@react-hookz/web'
import escapeStringRegexp from 'escape-string-regexp'
import flattenChildren from 'react-keyed-flatten-children'
import {
  createHighlighter,
  type BundledLanguage,
  type BundledTheme,
  type HighlighterGeneric,
  type ThemedToken,
} from 'shiki'

import type { GDSComponentProps } from '@graphprotocol/gds-css'
import {
  EyeClosedIcon,
  EyeIcon,
  FileCodeIcon,
  FileCssIcon,
  FileHtmlIcon,
  FileJsIcon,
  FileJsxIcon,
  FileMdIcon,
  FilePyIcon,
  FileRsIcon,
  FileSqlIcon,
  FileTsIcon,
  FileTsxIcon,
  FileVueIcon,
  TerminalIcon,
} from '@graphprotocol/gds-react/icons'

import { useControlled, useCSSPropsPolyfill, useGDS } from '../hooks/index.ts'
import { cn, getCSSPropsAttributes, type Stringifiable } from '../utils/index.ts'
import { renderAddon, type AddonValue } from './base/Addon.tsx'
import { Button } from './Button.tsx'
import { ButtonGroup } from './ButtonGroup.tsx'
import { CodeBlockMeta } from './CodeBlock.meta.ts'
import { CopyButton } from './CopyButton.tsx'
import { TabSet, TabSetContext, type TabSetTabProps } from './TabSet.tsx'

// Can't import `@shikijs/vscode-textmate`'s `FontStyle` enum due to `verbatimModuleSyntax`
const FontStyle = {
  NotSet: -1,
  None: 0,
  Italic: 1,
  Bold: 2,
  Underline: 4,
} as const

const THEMES = {
  dark: 'night-owl',
  light: 'min-light',
} satisfies Record<string, BundledTheme>

const SHIKI_LANGUAGES = [
  'shellscript',
  'shell',
  'bash',
  'sh',
  'zsh',
  'html',
  'markdown',
  'md',
  'css',
  'javascript',
  'js',
  'jsx',
  'typescript',
  'ts',
  'tsx',
  'vue',
  'json',
  'yaml',
  'yml',
  'toml',
  'graphql',
  'gql',
  'sql',
  'php',
  'java',
  'ruby',
  'rust',
  'rs',
  'python',
  'py',
  'go',
  'swift',
  'solidity',
  'mermaid',
] satisfies BundledLanguage[]
export const LANGUAGES = ['text', ...SHIKI_LANGUAGES] as const

type Language = (typeof LANGUAGES)[number]
type CodeChunk = ThemedToken & { highlighted: boolean; secret: boolean }
type CodeLine = { chunks: CodeChunk[]; style: 'added' | 'deleted' | null }

interface SharedProps extends GDSComponentProps<typeof CodeBlockMeta> {
  highlight?: string | string[] | undefined
  secret?: string | string[] | undefined
  /** @default true */
  masked?: boolean | undefined
  onMaskedChange?: ((masked: boolean) => void) | undefined
  /** @default true */
  copy?: boolean | string | undefined
  onCopy?: ((value: string) => void) | undefined
  /**
   * Whether to style diff-formatted lines. When this is `true`, each line of the passed code is
   * expected to be prefixed with a diff marker:
   *
   * - `+` for added lines.
   * - `-` for deleted lines.
   * - ` ` (space) for unchanged lines.
   *
   * Note that the diff markers and deleted lines are excluded from the code when copied via the
   * copy button, but are included when selected manually.
   *
   * @default false
   */
  diff?: boolean | undefined
}

function CodeBlockWrapper({ className, children, ...props }: ComponentProps<'figure'>) {
  return (
    <figure className={cn('root-flex flex-col bg-canvas', className)} {...props}>
      {children}
      <span className="pointer-events-none absolute inset-0 rounded-inherit border border-subtle" />
    </figure>
  )
}

const CodeBlockHeaderContext = createContext<boolean>(false)

interface CodeBlockHeaderProps extends ComponentProps<'figcaption'> {
  buttons?: ReactNode | undefined
}

function CodeBlockHeader({ buttons, className, children, ...props }: CodeBlockHeaderProps) {
  return (
    <CodeBlockHeaderContext.Provider value={true}>
      <figcaption
        className={cn(
          'root-flex h-10 shrink-0 gap-4 rounded-t-inherit bg-muted ps-2 pe-1',
          className,
        )}
        {...props}
      >
        <div className="min-w-em grow">{children}</div>
        {buttons ? (
          <ButtonGroup
            variant="naked"
            size="medium"
            /**
             * The `not-has-button:hidden` class prevents an empty `ButtonGroup` from taking space
             * in the layout, which can happen when the stuff that `CodeBlockTabs` passes as
             * `buttons` ends up rendering nothing (which we can't know in advance).
             */
            className="me-2 shrink-0 self-center not-has-button:hidden"
          >
            {buttons}
          </ButtonGroup>
        ) : null}
        <div className="absolute inset-x-0 bottom-0 border-b border-subtle" />
      </figcaption>
    </CodeBlockHeaderContext.Provider>
  )
}

export interface CodeBlockProps extends Omit<ComponentProps<'div'>, 'onCopy'>, SharedProps {
  /** @default 'text' */
  language?: Language | undefined
  /** Defaults to an icon that matches the `language`. */
  icon?: AddonValue | undefined
  label?: ReactNode | undefined
  children: Stringifiable
}

export function CodeBlockRoot({
  language = 'text',
  icon: passedIcon,
  label,
  highlight: passedHighlight,
  secret: passedSecret,
  masked: controlledMasked,
  onMaskedChange,
  copy: passedCopy,
  onCopy: passedOnCopy,
  wrap: passedWrap,
  diff: passedDiff,
  lineNumbers: passedLineNumbers,
  rows: passedRows,
  className,
  style,
  children,
  ...props
}: CodeBlockProps) {
  const { activeTheme } = useGDS()
  const tabsContext = useContext(CodeBlockTabsContext)
  const hasAncestorTabs = tabsContext !== null
  const hasAncestorHeader = useContext(CodeBlockHeaderContext) !== false

  const icon = passedIcon !== undefined ? passedIcon : getDefaultIcon(language)
  const tabsProps = hasAncestorTabs ? tabsContext.props : null
  const highlights = [passedHighlight ?? tabsProps?.highlight]
    .flat()
    .filter((value): value is string => Boolean(value))
  const secrets = [passedSecret ?? tabsProps?.secret]
    .flat()
    .filter((value): value is string => Boolean(value))
  const [masked, setMasked] = useControlled(
    controlledMasked ?? tabsProps?.masked,
    true,
    (masked: boolean) => {
      onMaskedChange?.(masked)
      tabsProps?.onMaskedChange?.(masked)
    },
  )
  const copy = passedCopy ?? tabsProps?.copy ?? true
  const onCopy = passedOnCopy ?? tabsProps?.onCopy
  const wrap = passedWrap ?? tabsProps?.wrap
  const diff = passedDiff ?? tabsProps?.diff ?? false
  const lineNumbers = passedLineNumbers ?? tabsProps?.lineNumbers
  const rows = passedRows ?? tabsProps?.rows

  const [cssPropsPolyfillPassedRef, cssPropsPolyfillAttributes] = useCSSPropsPolyfill(
    CodeBlockMeta,
    { wrap, lineNumbers },
    {},
  )

  const highlighter = useShikiHighlighter()
  const childrenString = Array.isArray(children) ? children.join('') : String(children)
  let code = childrenString
  let codeToCopy = childrenString
  let lineStyles: CodeLine['style'][] = []

  if (diff) {
    const linesWithDiffMarkers = code.split(/\r?\n/)
    const linesWithoutDiffMarkers = linesWithDiffMarkers.map((line) =>
      line[0] && ['+', '-', ' '].includes(line[0]) ? line.slice(1) : line,
    )
    lineStyles = linesWithDiffMarkers.map((line) => {
      const diffMarker = line[0]
      if (diffMarker === '+') return 'added'
      if (diffMarker === '-') return 'deleted'
      return null
    })
    code = linesWithoutDiffMarkers.join('\n')
    codeToCopy = linesWithoutDiffMarkers
      .filter((_line, lineIndex) => lineStyles[lineIndex] !== 'deleted')
      .join('\n')
  }

  const lines: CodeLine[] = useDeepCompareMemo(() => {
    if (hasAncestorHeader) return []

    const { tokens } = highlighter.codeToTokens(code, {
      theme: THEMES[activeTheme],
      lang: LANGUAGES.includes(language) ? language : 'text',
    })

    return tokens.map((lineTokens, lineIndex): CodeLine => {
      const lineContent = lineTokens.map((token: ThemedToken) => token.content).join('')
      const lineStyle = lineStyles[lineIndex] ?? null
      let chunks: CodeChunk[] = []

      if (!lineContent) {
        return { chunks, style: lineStyle }
      }

      if (highlights.length === 0 && secrets.length === 0) {
        chunks = lineTokens.map((token: ThemedToken) => ({
          ...token,
          highlighted: false,
          secret: false,
        }))
      } else {
        const highlightRanges = highlights.length > 0 ? findRanges(lineContent, highlights) : []
        const secretRanges = secrets.length > 0 ? findRanges(lineContent, secrets) : []
        let currentTokenIndex = 0
        let currentToken = lineTokens[0]!
        let currentChunk: CodeChunk = {
          ...currentToken,
          content: '',
          highlighted: isInRange(0, highlightRanges),
          secret: isInRange(0, secretRanges),
        }
        let currentOffset = currentChunk.offset

        for (let characterIndex = 0; characterIndex < lineContent.length; characterIndex++) {
          const character = lineContent[characterIndex]!
          const highlighted = isInRange(characterIndex, highlightRanges)
          const secret = isInRange(characterIndex, secretRanges)
          const moveToNextToken = currentOffset >= currentToken.offset + currentToken.content.length
          if (
            moveToNextToken ||
            highlighted !== currentChunk.highlighted ||
            secret !== currentChunk.secret
          ) {
            chunks.push(currentChunk)
            if (moveToNextToken) {
              currentTokenIndex++
              currentToken = lineTokens[currentTokenIndex]!
            }
            currentChunk = { ...currentToken, content: character, highlighted, secret }
          } else {
            currentChunk.content += character
          }
          currentOffset++
        }
        chunks.push(currentChunk)
      }

      // Split chunks by whitespace boundaries so each chunk is either pure whitespace or pure content
      chunks = chunks.flatMap((chunk) => {
        const parts = chunk.content.split(/(\s+)/).filter(Boolean)
        if (parts.length <= 1) return [chunk]
        return parts.map((part) => ({ ...chunk, content: part }))
      })

      return { chunks, style: lineStyle }
    })
  }, [highlighter, childrenString, activeTheme, language, highlights, secrets, diff])

  const buttonElements = [
    secrets.length > 0 && (
      <Button key="reveal" onClick={() => setMasked(!masked)}>
        {masked ? (
          <EyeIcon alt={`Reveal secret${secrets.length > 1 ? 's' : ''}`} />
        ) : (
          <EyeClosedIcon alt={`Hide secret${secrets.length > 1 ? 's' : ''}`} />
        )}
      </Button>
    ),
    copy && (
      <CopyButton key="copy" value={typeof copy === 'string' ? copy : codeToCopy} onCopy={onCopy} />
    ),
  ].filter(Boolean)

  const buttons = buttonElements.length > 0 ? <>{buttonElements}</> : null

  if (hasAncestorHeader) {
    return buttons
  }

  const Wrapper = hasAncestorTabs ? 'div' : CodeBlockWrapper
  const headerContent =
    label || passedIcon ? (
      <div className="flex h-full items-center gap-2 ps-2">
        {icon ? (
          <span className="flex size-4 shrink-0 items-center justify-center **:icon:default-size-3.5">
            {renderAddon(icon)}
          </span>
        ) : null}
        {label ? <span className="truncate text-14 text-muted">{label}</span> : null}
      </div>
    ) : null

  return (
    <Wrapper
      ref={cssPropsPolyfillPassedRef}
      data-has-header={Boolean(headerContent) || undefined}
      className={cn(
        `gds-code-block group/code-block root-flex flex-col
        u:not-in-code-block-tabs:rounded-8
        u:not-in-code-block-tabs:not-data-has-header:min-h-13
        u:not-in-code-block-tabs:data-has-header:min-h-24`,
        className,
      )}
      {...getCSSPropsAttributes(CodeBlockMeta, { wrap, lineNumbers, rows }, style)}
      {...cssPropsPolyfillAttributes}
      {...props}
    >
      {headerContent ? (
        hasAncestorTabs ? (
          <div className="flex h-10 shrink-0 px-2">
            <div className="absolute inset-0 border-b border-subtle" />
            {headerContent}
          </div>
        ) : (
          <CodeBlockHeader buttons={buttons}>{headerContent}</CodeBlockHeader>
        )
      ) : !hasAncestorTabs && buttons ? (
        <div
          className={`
            absolute end-3 top-3 z-10 opacity-0 transition
            group-not-hover/code-block:pointer-events-none
            group-hover/code-block:opacity-100
            has-focus-visible:opacity-100
          `}
        >
          <ButtonGroup size="xsmall">{buttons}</ButtonGroup>
        </div>
      ) : null}
      <pre
        dir="ltr"
        /**
         * The Firefox-specific styles are to work around `max-h-max` not working as expected (see
         * https://bugzilla.mozilla.org/show_bug.cgi?id=1672559). This means that a `CodeBlock` that
         * has a height (either fixed or determined by the flex algorithm) that is taller than
         * specified by the `rows` prop will be buggy in Firefox, but `rows` should not be set in
         * that case, so it should be fine.
         */
        className={`
          isolate scrollbar-thin h-(--max-height) max-h-max grow overflow-auto font-mono text-14
          [--max-height:calc(9999px-min(9999px,var(--gds-code-block-rows)*9999px)+var(--gds-code-block-rows)*1lh+2*var(--padding))]
          [--padding:--spacing(4)]
          firefox:h-auto
          firefox:max-h-(--max-height)
        `}
      >
        <code
          /**
           * The `min-w-max` class ensures that the right padding is preserved when there is
           * horizontal scrolling, while `auto-rows-max` works around a Safari bug where wrapped
           * lines have the wrong height.
           */
          className={`
            grid min-w-max auto-rows-max gap-x-2 py-(--padding)
            @prop-wrap-true/code-block:min-w-0
            @prop-line-numbers-true/code-block:grid-cols-[min-content_1fr]
          `}
        >
          {lines.map((line, lineIndex) => (
            <span
              key={lineIndex}
              aria-label={
                line.style === 'added'
                  ? 'Added line'
                  : line.style === 'deleted'
                    ? 'Deleted line'
                    : undefined
              }
              data-style={line.style || undefined}
              className={cn(
                'col-span-full grid min-h-lh grid-cols-subgrid items-baseline px-(--padding)',
                line.style
                  ? `data-[style=added]:bg-code-added
                    data-[style=added]:text-code-added
                    data-[style=deleted]:bg-code-deleted
                    data-[style=deleted]:text-code-deleted`
                  : null,
              )}
            >
              <span
                aria-hidden
                className={`
                  min-w-4 text-center text-12 text-subtle select-none
                  @prop-line-numbers-false/code-block:hidden
                `}
              >
                {lineIndex + 1}
              </span>
              <span>
                {diff ? (
                  <span aria-hidden>
                    {line.style === 'added' ? '+' : null}
                    {line.style === 'deleted' ? '-' : null}
                    {line.style === null ? ' ' : null}
                  </span>
                ) : null}
                {line.chunks.length === 0 ? (
                  // Ensure line numbers are correctly aligned even when the line is empty
                  <span className="select-none"> </span>
                ) : (
                  <span
                    className={`
                      @prop-wrap-true/code-block:wrap-anywhere
                      @prop-wrap-true/code-block:whitespace-pre-wrap
                    `}
                  >
                    {line.chunks.map((chunk, chunkIndex) => {
                      let chunkContent = chunk.content
                      if (chunk.secret && masked) {
                        chunkContent = '•'.repeat(chunkContent.length)
                      }
                      return (
                        <span
                          key={chunkIndex}
                          data-whitespace={chunk.content.trim() === '' || undefined}
                          data-highlighted={chunk.highlighted || undefined}
                          className={cn(
                            chunk.fontStyle === FontStyle.Bold ? 'font-bold' : null,
                            chunk.fontStyle === FontStyle.Italic ? 'italic' : null,
                            chunk.fontStyle === FontStyle.Underline ? 'underline' : null,
                            chunk.highlighted
                              ? `z-10 bg-code-highlighted py-1 font-bold
                              not-has-[+[data-highlighted]]:-mr-1
                              not-has-[+[data-highlighted]]:rounded-r-4
                              not-has-[+[data-highlighted]]:pr-1
                              not-[[data-highlighted]+*]:-ml-1
                              not-[[data-highlighted]+*]:rounded-l-4
                              not-[[data-highlighted]+*]:pl-1`
                              : 'z-20',
                            // Prevent wrapping between the indentation and the code
                            chunkIndex === 1 ? '[[data-whitespace]+*]:break-all' : null,
                          )}
                          style={{
                            color: chunk.color,
                            backgroundColor: chunk.bgColor,
                          }}
                        >
                          {chunkContent}
                        </span>
                      )
                    })}
                  </span>
                )}
              </span>
            </span>
          ))}
        </code>
      </pre>
    </Wrapper>
  )
}
CodeBlockRoot.displayName = 'CodeBlock'

const CodeBlockTabsContext = createContext<{
  props: Required<Omit<SharedProps, '__addonCompatible'>>
} | null>(null)

type Tab<T extends string = string> =
  | T
  | {
      value: T
      label?: TabSetTabProps['children']
      addonBefore?: TabSetTabProps['addonBefore']
      addonAfter?: TabSetTabProps['addonAfter']
      disabled?: TabSetTabProps['disabled']
    }

type TabValue<T extends Tab> = T extends { value: infer V } ? V : T

export interface CodeBlockTabsProps<
  Tabs extends readonly Tab[] = readonly Tab[],
  T extends TabValue<Tabs[number]> = TabValue<Tabs[number]>,
>
  extends Omit<ComponentProps<'div'>, 'onChange' | 'onCopy'>, SharedProps {
  tabs: Tabs & readonly Tab[]
  value?: T | undefined
  defaultValue?: T | undefined
  onChange?: ((value: T) => void) | undefined
}

export function CodeBlockTabs<const Tabs extends readonly Tab[], T extends TabValue<Tabs[number]>>({
  ref: passedRef,
  tabs: passedTabs,
  value,
  defaultValue,
  onChange,
  highlight,
  secret,
  masked: controlledMasked,
  onMaskedChange,
  copy,
  onCopy,
  wrap,
  diff,
  lineNumbers,
  rows,
  className,
  children,
  ...props
}: CodeBlockTabsProps<Tabs, T>) {
  if (passedTabs.length === 0) {
    throw new Error('[CodeBlock.Tabs] At least 1 tab is required')
  }
  const childrenArray = flattenChildren(children)
  if (passedTabs.length !== childrenArray.length) {
    throw new Error(
      `[CodeBlock.Tabs] The number of tabs (${passedTabs.length}) must match the number of children (${childrenArray.length})`,
    )
  }
  const tabs = passedTabs.map((tab, tabIndex) => {
    const tabConfig = typeof tab === 'string' ? { value: tab, label: tab } : tab
    return { ...tabConfig, content: childrenArray[tabIndex] }
  })

  const [masked, setMasked] = useControlled(controlledMasked, undefined, onMaskedChange)

  const tabsContext = {
    props: {
      highlight,
      secret,
      masked,
      onMaskedChange: setMasked,
      copy,
      onCopy,
      wrap,
      diff,
      lineNumbers,
      rows,
    },
  }

  return (
    <TabSet
      ref={passedRef}
      value={value}
      defaultValue={defaultValue ?? (tabs[0]!.value as T)}
      onChange={onChange}
      className={cn(
        `gds-code-block-tabs root-flex flex-col u:min-h-24 u:rounded-8
        u:[--saved-default-line-numbers:var(--gds-default-line-numbers)]
        u:[--saved-default-rows:var(--gds-default-rows)]
        u:[--saved-default-wrap:var(--gds-default-wrap)]
        u:[--saved-prop-line-numbers:var(--gds-prop-line-numbers)]
        u:[--saved-prop-rows:var(--gds-prop-rows)]
        u:[--saved-prop-wrap:var(--gds-prop-wrap)]
        u:**:code-block:default-line-numbers-(--saved-default-line-numbers)
        u:**:code-block:default-rows-(--saved-default-rows)
        u:**:code-block:default-wrap-(--saved-default-wrap)
        u:**:code-block:prop-line-numbers-(--saved-prop-line-numbers)
        u:**:code-block:prop-rows-(--saved-prop-rows)
        u:**:code-block:prop-wrap-(--saved-prop-wrap)`,
        className,
      )}
      {...props}
    >
      <CodeBlockWrapper className="grow rounded-inherit">
        <CodeBlockHeader
          buttons={
            <CodeBlockTabsContext.Provider value={tabsContext}>
              <TabSetContext.Consumer>
                {(tabSetContext) =>
                  tabs.map((tab) => (tabSetContext.selectedTab === tab.value ? tab.content : null))
                }
              </TabSetContext.Consumer>
            </CodeBlockTabsContext.Provider>
          }
        >
          <TabSet.Tabs size="small" divider={false} className="z-10 h-full">
            {tabs.map((tab) => (
              <TabSet.Tab
                key={tab.value}
                value={tab.value}
                addonBefore={tab.addonBefore}
                addonAfter={tab.addonAfter}
                disabled={tab.disabled}
              >
                {tab.label ?? tab.value}
              </TabSet.Tab>
            ))}
          </TabSet.Tabs>
        </CodeBlockHeader>
        <TabSet.Panels transition={{ animateSize: 'clip' }} className="grow">
          {tabs.map((tab) => (
            <TabSet.Panel key={tab.value} value={tab.value} className="flex flex-col *:grow">
              <CodeBlockTabsContext.Provider value={tabsContext}>
                {tab.content}
              </CodeBlockTabsContext.Provider>
            </TabSet.Panel>
          ))}
        </TabSet.Panels>
      </CodeBlockWrapper>
    </TabSet>
  )
}
CodeBlockTabs.displayName = 'CodeBlock.Tabs'

function getDefaultIcon(language: Language) {
  switch (language) {
    case 'shellscript':
    case 'shell':
    case 'bash':
    case 'sh':
    case 'zsh':
      return <TerminalIcon alt="Terminal" />
    case 'html':
      return <FileHtmlIcon alt="HTML" />
    case 'markdown':
    case 'md':
      return <FileMdIcon alt="Markdown" />
    case 'css':
      return <FileCssIcon alt="CSS" />
    case 'javascript':
    case 'js':
      return <FileJsIcon alt="JavaScript" />
    case 'jsx':
      return <FileJsxIcon alt="JSX" />
    case 'typescript':
    case 'ts':
      return <FileTsIcon alt="TypeScript" />
    case 'tsx':
      return <FileTsxIcon alt="TSX" />
    case 'vue':
      return <FileVueIcon alt="Vue.js" />
    case 'sql':
      return <FileSqlIcon alt="SQL" />
    case 'rust':
    case 'rs':
      return <FileRsIcon alt="Rust" />
    case 'python':
    case 'py':
      return <FilePyIcon alt="Python" />
    default:
      return <FileCodeIcon alt="" />
  }
}

/**
 * Creates a hook that returns a Shiki highlighter.
 *
 * Shiki's `createHighlighter` is async, so we call it immediately and use `useSyncExternalStore` to
 * re-render components when it resolves. Until then, we return a fallback highlighter that renders
 * code as plain text (no syntax highlighting).
 */
function createUseShikiHighlighter(themes: BundledTheme[], langs: BundledLanguage[]) {
  type Highlighter = {
    codeToTokens: HighlighterGeneric<BundledLanguage, BundledTheme>['codeToTokens']
  }

  const fallback: Highlighter = {
    codeToTokens: (code: string) => {
      let offset = 0
      return {
        tokens: code.split('\n').map((line) => {
          const lineTokens = [{ offset, content: line }]
          offset += line.length + 1 // +1 for the newline character
          return lineTokens
        }),
      }
    },
  }

  let highlighter: Highlighter | null = null
  const listeners = new Set<() => void>()

  void createHighlighter({ themes, langs }).then((h) => {
    highlighter = h
    listeners.forEach((listener) => listener())
  })

  return function useShikiHighlighter() {
    return useSyncExternalStore(
      (listener) => {
        listeners.add(listener)
        return () => listeners.delete(listener)
      },
      () => highlighter ?? fallback,
      () => fallback,
    )
  }
}

const useShikiHighlighter = createUseShikiHighlighter(Object.values(THEMES), SHIKI_LANGUAGES)

function findRanges(string: string, substrings: string[]): { start: number; end: number }[] {
  const ranges: { start: number; end: number }[] = []
  substrings.forEach((substring) => {
    const regex = new RegExp(escapeStringRegexp(substring), 'gi')
    let match
    while ((match = regex.exec(string)) !== null) {
      ranges.push({ start: match.index, end: match.index + match[0].length })
      regex.lastIndex = match.index + 1 // Ensure `findRanges('aaa', ['aa'])` returns two ranges, not one
    }
  })
  return ranges
}

function isInRange(index: number, ranges: { start: number; end: number }[]) {
  return ranges.some((range) => index >= range.start && index < range.end)
}
