import { html, LitElement, TemplateResult, PropertyValues } from 'lit'
import { property } from 'lit/decorators.js'
import DOMPurify from 'dompurify'
import { marked, MarkedOptions } from 'marked'
// import { PrismHighlighter } from './PrismHighlighter.js';

/**
Element wrapper for the [marked](https://github.com/chjj/marked) library.

`<marked-highlight>` accepts Markdown source and renders it to a child
element with the class `markdown-html`. This child element can be styled
as you would a normal DOM element. If you do not provide a child element
with the `markdown-html` class, the Markdown source will still be rendered,
but to a shadow DOM child that cannot be styled.

### Markdown Content

The Markdown source can be specified several ways:

#### Use the `markdown` attribute to bind markdown

```html
<marked-highlight markdown="`Markdown` is _awesome_!">
  <div slot="markdown-html"></div>
</marked-highlight>
```
#### Use `<script type="text/markdown">` element child to inline markdown

```html
<marked-highlight>
  <div slot="markdown-html"></div>
  <script type="text/markdown">
    Check out my markdown!
    We can even embed elements without fear of the HTML parser mucking up their
    textual representation:
  </script>
</marked-highlight>
```
#### Use `<script type="text/markdown" src="URL">` element child to specify remote markdown

```html
<marked-highlight>
  <div slot="markdown-html"></div>
  <script type="text/markdown" src="../guidelines.md"></script>
</marked-highlight>
```

Note that the `<script type="text/markdown">` approach is *static*. Changes to
the script content will *not* update the rendered markdown!

Though, you can data bind to the `src` attribute to change the markdown.

```html
<marked-highlight>
  <div slot="markdown-html"></div>
  <script type="text/markdown" src$="[[source]]"></script>
</marked-highlight>
<script>
  ...
  this.source = '../guidelines.md';
</script>
```

### Styling

If you are using a child with the `markdown-html` class, you can style it
as you would a regular DOM element:

```css
[slot="markdown-html"] p {
  color: red;
}
[slot="markdown-html"] td:first-child {
  padding-left: 24px;
}
```

@fires markedrendercomplete - Event dispatched when the element renders the output.
@fires markedloaded - Event dispatched when the element loads the script from the URL.
@fires markedloaderror - An event dispatched when an error ocurred when downloading
                         the script content. The detail has the original error.
 */
export default class MarkedHighlight extends LitElement {
  /**
   * The markdown source to be rendered by this element.
   * @attribute
   */
  @property({ type: String }) accessor markdown: string | undefined

  /**
   * Enable GFM line breaks (regular newlines instead of two spaces for
   * breaks)
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) accessor breaks: boolean | undefined

  /**
   * Conform to obscure parts of markdown.pl as much as possible. Don't fix
   * any of the original markdown bugs or poor behavior.
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) accessor pedantic: boolean | undefined

  /**
   * Function used to customize a renderer based on the [API specified in the
   * Marked
   * library](https://github.com/chjj/marked#overriding-renderer-methods).
   * It takes one argument: a marked renderer object, which is mutated by the
   * function.
   */
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
  @property({ type: Object }) accessor renderer: Function | undefined

  /**
   * Sanitize the output. Ignore any HTML that has been input.
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) accessor sanitize: boolean | undefined

  /**
   * Function used to customize a sanitize behavior.
   * It takes one argument: element String without text Contents.
   *
   * e.g. `<div>` `<a href="/">` `</p>'.
   * Note: To enable this function, must set `sanitize` to true.
   * WARNING: If you are using this option to un-trusted text, you must to
   * prevent XSS Attacks.
   */
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
  @property({ type: Object }) accessor sanitizer: Function | undefined

  /**
   * If true, disables the default sanitization of any markdown received by
   * a request and allows fetched un-sanitized markdown
   *
   * e.g. fetching markdown via `src` that has HTML.
   * Note: this value overrides `sanitize` if a request is made.
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) accessor disableRemoteSanitization: boolean | undefined

  /**
   * @returns A reference to the output element.
   */
  get outputElement(): HTMLElement | null {
    const slot = this.shadowRoot?.querySelector('slot[name="markdown-html"]') as HTMLSlotElement | null
    if (!slot) {
      return null
    }
    const nodes = slot.assignedNodes()
    const child = nodes.find(
      (node) => node.nodeType === 1 && (node as HTMLElement).getAttribute('slot') === 'markdown-html'
    ) as HTMLElement | undefined
    return child || (this.shadowRoot?.querySelector('#content') as HTMLElement | null)
  }

  protected override willUpdate(cp: PropertyValues<this>): void {
    super.willUpdate(cp)
    if (
      cp.has('markdown') ||
      cp.has('sanitizer') ||
      cp.has('sanitize') ||
      cp.has('renderer') ||
      cp.has('pedantic') ||
      cp.has('breaks')
    ) {
      this.renderMarkdown()
    }
  }

  protected markdownElement?: HTMLScriptElement | null

  protected attachedValue = false

  override firstUpdated(): void {
    if (this.markdown) {
      this.renderMarkdown()
      return
    }
  }

  override connectedCallback(): void {
    super.connectedCallback()
    this.attachedValue = true
    this.processScript()
    this.renderMarkdown()
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback()
    this.attachedValue = false
  }

  protected async processScript(): Promise<void> {
    // Use the Markdown from the first `<script>` descendant whose MIME type
    // starts with "text/markdown". Script elements beyond the first are
    // ignored.
    const script = this.querySelector('[type="text/markdown"]') as HTMLScriptElement | null
    if (!script) {
      return
    }
    this.markdownElement = script

    if (script.src) {
      this.requestMarkdown(script.src)
    }
    const content = script.textContent
    if (content && content.trim() !== '') {
      this.markdown = this.unindent(content)
    }

    const observer = new MutationObserver(this.scriptAttributeHandler.bind(this))
    observer.observe(script, { attributes: true })
  }

  /**
   * Renders `markdown` into this element's DOM.
   *
   * This is automatically called whenever the `markdown` property is changed.
   *
   * The only case where you should be calling this is if you are providing
   * markdown via `<script type="text/markdown">` after this element has been
   * constructed (or updating that markdown).
   * @event markedrendercomplete
   */
  renderMarkdown(): void {
    if (!this.attachedValue) {
      return
    }
    const node = this.outputElement
    if (!node) {
      return
    }

    if (!this.markdown) {
      node.innerHTML = ''
      return
    }
    const renderer = new marked.Renderer()
    if (this.renderer) {
      this.renderer(renderer)
    }
    const data = this.markdown
    const opts: MarkedOptions = {
      renderer,
      breaks: this.breaks,
      pedantic: this.pedantic,
      gfm: true,
    }
    // eslint-disable-next-line no-misleading-character-class
    let out = marked(data.replace(/^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/, ''), opts) as string
    if (this.sanitize) {
      if (this.sanitizer) {
        out = this.sanitizer(out)
      } else {
        const result = DOMPurify.sanitize(out)
        if (typeof result === 'string') {
          out = result
        } else {
          // @ts-expect-error Sometimes the result is an object!
          out = result.toString()
        }
      }
    }
    node.innerHTML = out
    this.dispatchEvent(new Event('markedrendercomplete'))
  }

  /**
   * Un-indents the markdown source that will be rendered.
   */
  unindent(text?: string): string {
    if (!text) {
      return ''
    }
    const lines = text.replace(/\t/g, '  ').split('\n')
    const indent = lines.reduce((prev: number | null, line: string): number | null => {
      if (/^\s*$/.test(line)) {
        return prev // Completely ignore blank lines.
      }
      const match = line.match(/^(\s*)/)
      if (!match) {
        return prev
      }

      const lineIndent = match[0].length
      if (prev === null) {
        return lineIndent
      }

      return lineIndent < prev ? lineIndent : prev
    }, null)

    if (indent === null) {
      return text
    }

    return lines.map((l) => l.substring(indent)).join('\n')
  }

  /**
   * Fired when the XHR finishes loading
   */
  protected async requestMarkdown(url: string): Promise<void> {
    try {
      const response = await fetch(url, {
        headers: { accept: 'text/markdown' },
      })
      const { status } = response
      if (status === 0 || (status >= 200 && status < 300)) {
        this.sanitize = !this.disableRemoteSanitization
        this.markdown = await response.text()
        this.dispatchEvent(new Event('markedloaded'))
      } else {
        throw new Error('Unable to download the data')
      }
    } catch (e) {
      this.handleError(e as Error)
    }
  }

  /**
   * Fired when an error is received while fetching remote markdown content.
   */
  protected handleError(e: Error): void {
    const evt = new CustomEvent('markedloaderror', {
      composed: true,
      bubbles: true,
      cancelable: true,
      detail: e,
    })
    this.dispatchEvent(evt)
    if (!evt.defaultPrevented) {
      this.markdown = 'Failed loading markdown source'
    }
  }

  protected scriptAttributeHandler(mutation: MutationRecord[]): void {
    if (mutation[0].attributeName !== 'src') {
      return
    }
    const elm = this.markdownElement
    if (elm) {
      this.requestMarkdown(elm.src)
    }
  }

  override render(): TemplateResult {
    return html`<slot name="markdown-html"><div id="content"></div></slot>`
  }
}
