import { type Mermaid, type MermaidConfig } from 'mermaid'
import { type BrowserType, chromium, type LaunchOptions, type Page } from 'playwright'

declare const mermaid: Mermaid

const html = import.meta.resolve('../index.html')
const mermaidScript = {
  url: import.meta.resolve('mermaid/dist/mermaid.js')
}
const faStyle = {
  // We use url, not path. If we use path, the fonts can’t be resolved.
  url: import.meta.resolve('@fortawesome/fontawesome-free/css/all.css')
}

export interface CreateMermaidRendererOptions {
  /**
   * The Playwright browser to use.
   *
   * @default chromium
   */
  browserType?: BrowserType

  /**
   * The options used to launch the browser.
   */
  launchOptions?: LaunchOptions
}

export interface RenderResult {
  /**
   * The aria description of the diagram.
   */
  description?: string

  /**
   * The height of the resulting SVG.
   */
  height: number

  /**
   * The DOM id of the SVG node.
   */
  id: string

  /**
   * The diagram SVG rendered as a PNG buffer.
   */
  screenshot?: Buffer

  /**
   * The diagram rendered as an SVG.
   */
  svg: string

  /**
   * The title of the rendered diagram.
   */
  title?: string

  /**
   * The width of the resulting SVG.
   */
  width: number
}

export interface RenderOptions {
  /**
   * A style to apply to the container used to render the diagram.
   *
   * Certain styling is known to override the rendering behaviour. For example, the `maxWidth`
   * property affects gantt diagrams.
   *
   * @default { maxHeight: '0', opacity: '0', overflow: 'hidden' }
   */
  containerStyle?: Partial<CSSStyleDeclaration>

  /**
   * A URL that points to a custom CSS file to load.
   *
   * Use this to load custom fonts.
   *
   * This option is ignored in the browser. You need to include the CSS in your build manually.
   */
  css?: Iterable<URL | string> | URL | string | undefined

  /**
   * If true, a PNG screenshot of the diagram will be added.
   *
   * This is only supported in the Node.js.
   */
  screenshot?: boolean

  /**
   * The mermaid configuration.
   *
   * By default `fontFamily` is set to `arial,sans-serif`.
   *
   * This option is ignored in the browser. You need to call `mermaid.initialize()` manually.
   */
  mermaidConfig?: MermaidConfig

  /**
   * The prefix of the id.
   *
   * @default 'mermaid'
   */
  prefix?: string | undefined
}

/**
 * Render Mermaid diagrams in the browser.
 *
 * @param diagrams
 *   The Mermaid diagrams to render.
 * @param options
 *   Additional options to use when rendering the diagrams.
 * @returns
 *   A list of settled promises that contains the rendered Mermaid diagram. Each result matches the
 *   same index of the input diagrams.
 */
export type MermaidRenderer = (
  diagrams: string[],
  options?: RenderOptions
) => Promise<PromiseSettledResult<RenderResult>[]>

interface RenderDiagramsOptions
  extends Required<
    Pick<RenderOptions, 'containerStyle' | 'mermaidConfig' | 'prefix' | 'screenshot'>
  > {
  /**
   * The diagrams to process.
   */
  diagrams: string[]
}

/* c8 ignore start */
/**
 * Render mermaid diagrams in the browser.
 *
 * @param options
 *   The options used to render the diagrams
 * @returns
 *   A settled promise that holds the rendering results.
 */
async function renderDiagrams({
  containerStyle,
  diagrams,
  mermaidConfig,
  prefix,
  screenshot
}: RenderDiagramsOptions): Promise<PromiseSettledResult<RenderResult>[]> {
  await Promise.all(Array.from(document.fonts, (font) => font.load()))
  const parser = new DOMParser()
  const serializer = new XMLSerializer()
  const container = document.createElement('div')
  container.ariaHidden = 'true'
  container.style.maxHeight = '0'
  container.style.opacity = '0'
  container.style.overflow = 'hidden'
  Object.assign(container.style, containerStyle)

  document.body.append(container)
  mermaid.initialize(mermaidConfig)

  /**
   * Get an aria value form a referencing attribute.
   *
   * @param element
   *   The SVG element the get the value from.
   * @param attribute
   *   The attribute whose value to get.
   * @returns
   *   The aria value.
   */
  // eslint-disable-next-line unicorn/consistent-function-scoping
  function getAriaValue(element: SVGSVGElement, attribute: string): string | undefined {
    const value = element.getAttribute(attribute)
    if (!value) {
      return
    }

    let result = ''
    for (const id of value.split(/\s+/)) {
      const node = element.getElementById(id)
      if (node) {
        result += node.textContent
      }
    }
    return result
  }

  return Promise.allSettled(
    diagrams.map(async (diagram, index) => {
      const id = `${prefix}-${index}`

      try {
        const { svg } = await mermaid.render(id, diagram, container)
        const root = parser.parseFromString(svg, 'text/html')
        const [element] = root.getElementsByTagName('svg')
        const { height, width } = element.viewBox.baseVal
        const description = getAriaValue(element, 'aria-describedby')
        const title = getAriaValue(element, 'aria-labelledby')

        if (screenshot) {
          document.body.append(element)
        }

        const result: RenderResult = {
          height,
          id,
          svg: serializer.serializeToString(element),
          width
        }

        if (description) {
          result.description = description
        }

        if (title) {
          result.title = title
        }

        return result
      } catch (error) {
        throw error instanceof Error
          ? { name: error.name, stack: error.stack, message: error.message }
          : error
      }
    })
  )
}

/* c8 ignore stop */

interface SimpleContext {
  /**
   * Gracefully close the browser context and the browser.
   */
  close: () => Promise<undefined>

  /**
   * Open a new page.
   */
  newPage: () => Promise<Page>
}

/**
 * Launch a browser and a single browser context.
 *
 * @param browserType
 *   The browser type to launch.
 * @param launchOptions
 *   Optional launch options
 * @returns
 *   A simple browser context wrapper
 */
async function getBrowser(
  browserType: BrowserType,
  launchOptions: LaunchOptions | undefined
): Promise<SimpleContext> {
  const browser = await browserType.launch(launchOptions)
  const context = await browser.newContext({ bypassCSP: true })

  return {
    async close() {
      await context.close()
      await browser.close()
    },

    newPage() {
      return context.newPage()
    }
  }
}

/**
 * Create a Mermaid renderer.
 *
 * The Mermaid renderer manages a browser instance. If multiple diagrams are being rendered
 * simultaneously, the internal browser instance will be re-used. If no diagrams are being rendered,
 * the browser will be closed.
 *
 * @param options
 *   The options of the Mermaid renderer.
 * @returns
 *   A function that renders Mermaid diagrams in the browser.
 */
export function createMermaidRenderer(options: CreateMermaidRendererOptions = {}): MermaidRenderer {
  const { browserType = chromium, launchOptions } = options

  let browserPromise: Promise<SimpleContext> | undefined
  let count = 0

  return async (diagrams, renderOptions) => {
    count += 1
    if (!browserPromise) {
      browserPromise = getBrowser(browserType, launchOptions)
    }

    const context = await browserPromise

    let page: Page | undefined
    let renderResults: PromiseSettledResult<RenderResult>[]

    try {
      page = await context.newPage()
      await page.goto(html)
      const promises = [page.addStyleTag(faStyle), page.addScriptTag(mermaidScript)]
      const css = renderOptions?.css
      if (typeof css === 'string' || css instanceof URL) {
        promises.push(page.addStyleTag({ url: String(css) }))
      } else if (css) {
        for (const url of css) {
          promises.push(page.addStyleTag({ url: String(url) }))
        }
      }
      await Promise.all(promises)

      renderResults = await page.evaluate(renderDiagrams, {
        // Avoid error TS2589: Type instantiation is excessively deep and possibly infinite.
        containerStyle: (renderOptions?.containerStyle ?? {}) as object,
        diagrams,
        screenshot: Boolean(renderOptions?.screenshot),
        mermaidConfig: {
          fontFamily: 'arial,sans-serif',
          ...renderOptions?.mermaidConfig
        },
        prefix: renderOptions?.prefix ?? 'mermaid'
      })
      if (renderOptions?.screenshot) {
        for (const result of renderResults) {
          if (result.status === 'fulfilled') {
            result.value.screenshot = await page
              .locator(`#${result.value.id}`)
              .screenshot({ omitBackground: true })
          }
        }
      }
    } finally {
      await page?.close()
      count -= 1
      if (!count) {
        browserPromise = undefined
        context.close()
      }
    }

    for (const result of renderResults) {
      if (result.status !== 'rejected') {
        continue
      }

      const { reason } = result

      if (reason && 'name' in reason && 'message' in reason && 'stack' in reason) {
        Object.setPrototypeOf(reason, Error.prototype)
      }
    }

    return renderResults
  }
}
