import { LitElement, html, TemplateResult, PropertyValues } from 'lit'
import {
  type DeserializedPayload,
  type ISafePayload,
  type Payload,
  PayloadSerializer,
} from '@api-client/core/lib/transformers/PayloadSerializer.js'
import { property, state } from 'lit/decorators.js'
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
import { PrismHighlighter } from '../highlight/PrismHighlighter.js'
import { getLanguage } from '../highlight/PrismAutoDetect.js'
import { ensureBodyString, imageBody, isBinaryBody, isPdfBody, isTextBody } from '../../lib/http/Payload.js'

import 'prismjs/prism.js'
import 'prismjs/components/prism-json.js'
import 'prismjs/components/prism-javascript.js'
import 'prismjs/components/prism-markdown.js'
import 'prismjs/components/prism-markup.js'
import 'prismjs/components/prism-yaml.js'
import 'prismjs/components/prism-xml-doc.js'
import 'prismjs/components/prism-http.js'
import 'prismjs/plugins/line-numbers/prism-line-numbers.min.js'

enum BodyType {
  // null, undefined, empty
  nil,
  // a string value
  String,
  Blob,
  File,
  // Multipart FormData object
  FormData,
  // Buffer, ArrayBuffer, UInt8Array, generally binary.
  Buffer,
}

enum ViewType {
  Parsed,
  ImageSvg,
  ImageBinary,
  ImageError,
  Pdf,
  Binary,
  // application/x-www-form-urlencoded
  UrlEncoded,
}

/**
 * Renders the view of the payload message.
 */
export default class LogBody extends LitElement {
  /**
   * The payload to render. When the payload requires de-serialization it will be performed.
   */
  @property() accessor payload: DeserializedPayload | Payload | undefined

  /**
   * The content type of the payload.
   * @attribute
   */
  @property({ type: String, reflect: true }) accessor contentType: string | undefined

  /**
   * When set it does not apply formatting for the code. However, it keeps the highlighting.
   * @attribute
   */
  @property({ type: Boolean }) accessor raw = false

  @state() protected accessor renderBody: DeserializedPayload | undefined

  @state() protected accessor bodyType: BodyType = BodyType.nil

  @state() protected accessor viewType: ViewType = ViewType.Parsed

  /**
   * The tokens rendered in the view.
   */
  @state() protected accessor renderTokens: string | undefined

  override willUpdate(cp: PropertyValues<this>): void {
    if (cp.has('payload') || cp.has('contentType')) {
      this.prepareContent()
    }
  }

  override updated(cp: PropertyValues): void {
    super.updated(cp)
    if (cp.has('bodyType') || cp.has('viewType')) {
      this.prismComplete()
    }
  }

  /**
   * When processing a payload from the request editor then the value can be any of the `DeserializedPayload` types.
   * However, when processing a response that came from the `CoreEngine` it is always a Buffer / ArrayBuffer
   * and the contents is determined by the content type.
   */
  protected async prepareContent(): Promise<void> {
    const { contentType = '', payload } = this
    this.renderBody = undefined
    this.renderTokens = undefined
    this.viewType = ViewType.Parsed
    this.bodyType = BodyType.nil

    if (!payload) {
      this.bodyType = BodyType.nil
      return
    }
    const body = await this.deserialize(payload)
    if (body === undefined || body === null || !body) {
      this.bodyType = BodyType.nil
      return
    }

    const typedSerialized = payload as ISafePayload

    if (contentType) {
      const image = imageBody(contentType)
      if (isTextBody(contentType)) {
        this.renderBody = ensureBodyString(body)
        if (image === 'svg') {
          this.viewType = ViewType.ImageSvg
        }
      } else if (image === 'binary') {
        this.viewType = ViewType.ImageBinary
        this.renderBody = body
      } else if (isPdfBody(contentType)) {
        this.viewType = ViewType.Pdf
        this.renderBody = body
      } else if (isBinaryBody(contentType)) {
        this.viewType = ViewType.Binary
        this.renderBody = body
      } else if (body instanceof FormData) {
        this.viewType = ViewType.Parsed
        this.renderBody = body
      } else if (typedSerialized.type === 'file' || typedSerialized.type === 'blob') {
        this.viewType = ViewType.Binary
        this.renderBody = body
      } else if (contentType.includes('application/x-www-form-urlencoded')) {
        this.viewType = ViewType.UrlEncoded
        this.renderBody = body
      } else {
        this.viewType = ViewType.Parsed
        this.renderBody = ensureBodyString(body)
      }
    } else {
      this.renderBody = body
      this.viewType = ViewType.Parsed
    }
    this.bodyType = this.typeFromType(this.renderBody)
    this.tokenize()
  }

  protected typeFromType(type: unknown): BodyType {
    if (type === undefined || type === null || !type) {
      return BodyType.nil
    }
    if (typeof type === 'string') {
      return BodyType.String
    }
    if (type instanceof File) {
      return BodyType.File
    }
    if (type instanceof Blob) {
      return BodyType.Blob
    }
    if (type instanceof FormData) {
      return BodyType.FormData
    }
    return BodyType.Buffer
  }

  protected async deserialize(value: Payload | DeserializedPayload): Promise<DeserializedPayload> {
    if (value === undefined || value === null) {
      // both values should be stored correctly
      return undefined
    }
    if (typeof value === 'string') {
      return value
    }
    if ((value as ISafePayload).data) {
      return PayloadSerializer.deserialize(value as ISafePayload)
    }
    return value as DeserializedPayload
  }

  /**
   * Depending on the body type and the contentType, it prepares the tokens to be rendered in the view.
   */
  protected tokenize(): void {
    const { contentType, bodyType: type, renderBody: body } = this
    // if (raw && type !== BodyType.FormData) {
    //   this._tokens = body as string;
    //   return;
    // }
    switch (type) {
      case BodyType.String:
        this.renderTokens = this.tokenizeStringContent(body as string, contentType)
        break
      case BodyType.FormData:
        this.renderTokens = this.tokenizeFormDataContent(body as FormData)
        break
      case BodyType.Buffer:
        this.renderTokens = this.tokenizeBuffer(body as ArrayBuffer, contentType)
        break
      case BodyType.File:
      case BodyType.Blob:
        this.renderTokens = this.tokenizeFile()
        break
      default:
        this.renderTokens = undefined
        break
    }
  }

  protected tokenizeStringContent(body: string, contentType = ''): string {
    const { raw } = this
    if (raw) {
      return body
    }
    const finalBody = this.formatBody(body, contentType)
    const info = getLanguage(contentType)
    const env = {
      code: finalBody,
      grammar: info.grammar,
      language: info.lang,
    }
    // @ts-expect-error There are not types for Prism
    Prism.hooks.run('before-highlight', env)
    // @ts-expect-error There are not types for Prism
    return Prism.highlight(finalBody, info.grammar, info.lang)
  }

  protected formatBody(body: string, contentType = ''): string {
    let result = body
    if (contentType.includes('json')) {
      try {
        const parsed = JSON.parse(result)
        result = JSON.stringify(parsed, null, 2)
      } catch {
        // ...
      }
    }
    return result
  }

  protected tokenizeFormDataContent(body: FormData): string {
    const { raw } = this
    const boundary = '7MA4YWxkTrZu0gW'
    const lineBreak = '\r\n'
    const templates: string[] = []
    body.forEach((value: FormDataEntryValue, key: string) => {
      if (typeof value === 'string') {
        templates.push(`--${boundary}${lineBreak}`)
        templates.push(`Content-Disposition: form-data; name="${key}"${lineBreak}${lineBreak}`)
        templates.push(`${value}${lineBreak}`)
      } else {
        templates.push(`--${boundary}${lineBreak}`)
        templates.push(`Content-Disposition: form-data; name="${key}"; filename="${value.name}"${lineBreak}`)
        templates.push(`Content-Type: ${value.type || 'application/octet-stream'}${lineBreak}${lineBreak}`)
        templates.push(`{…file content…}${lineBreak}`)
      }
    })
    templates.push(`--${boundary}--${lineBreak}`)
    const code = templates.join('')
    if (raw) {
      return code
    }
    const highlight = new PrismHighlighter()
    return highlight.tokenize(code, 'http')
  }

  protected tokenizeImageData(body: ArrayBuffer, contentType?: string): string | undefined {
    try {
      const arr = new Uint8Array(body)
      const str = arr.reduce((data, byte) => data + String.fromCharCode(byte), '')
      const enc = btoa(str)
      return `data:${contentType};base64, ${enc}`
    } catch {
      return undefined
    }
  }

  protected tokenizeBuffer(body: ArrayBuffer, contentType?: string): string | undefined {
    if (typeof body === 'string') {
      return undefined
    }
    if (this.viewType === ViewType.ImageBinary) {
      return this.tokenizeImageData(body, contentType)
    }

    try {
      const array = new Uint8Array(body).join(', ')
      return `[${array}]`
    } catch {
      return undefined
    }
  }

  protected tokenizeFile(): string | undefined {
    const { payload } = this
    if (!payload) {
      return undefined
    }
    const typed = payload as ISafePayload
    if (typed.type !== 'file' && typed.type !== 'blob') {
      return undefined
    }
    if (!Array.isArray(typed.data)) {
      return undefined
    }
    const array = (typed.data as number[]).join(', ')
    return `[${array}]`
  }

  protected prismComplete(): void {
    const { renderBody, renderTokens } = this
    const element = this.shadowRoot?.querySelector('#rawCode')
    if (!element) {
      return
    }
    let body = ''
    if (typeof renderBody === 'string') {
      body = renderBody
    } else if (typeof renderTokens === 'string') {
      body = renderTokens
    }
    const env = {
      code: body,
      element,
    }
    try {
      // @ts-expect-error There are not types for Prism
      Prism.hooks.run('complete', env)

      // @ts-expect-error There are not types for Prism
      Prism.plugins.lineNumbers.resize(element.parentElement as Element)
    } catch {
      // ...
    }
  }

  /**
   * A handler for the image error event.
   * This occurs when rendering an image via the img tag but the image URL is invalid.
   */
  protected handleImageError(): void {
    this.viewType = ViewType.ImageError
  }

  protected override render(): TemplateResult {
    const { renderBody: body, bodyType: type, viewType: view } = this
    if (type === BodyType.nil) {
      return html`<p class="no-info">No data available.</p>`
    }
    if (view === ViewType.ImageError) {
      return this.renderImageError()
    }
    if (view === ViewType.Parsed) {
      return this.renderStringBody()
    }
    if (view === ViewType.ImageSvg) {
      return this.renderImageSvgBody()
    }
    if (view === ViewType.ImageBinary) {
      return this.renderImageBinaryBody()
    }
    if (view === ViewType.Pdf) {
      return this.renderPdfBody()
    }
    if (view === ViewType.UrlEncoded) {
      return this.renderUrlEncodedBody()
    }
    if (type === BodyType.Blob || type === BodyType.File) {
      return this.renderBlobBody(body as Blob)
    }
    // only buffers left at this point
    return this.renderBufferBody(body as ArrayBuffer)
  }

  protected renderStringBody(): TemplateResult {
    const { raw, renderTokens } = this
    const content = raw ? renderTokens : unsafeHTML(renderTokens)
    return html`
      <pre
        class="raw-panel string language-snippet line-numbers"
      ><code id="rawCode" class="code">${content}</code></pre>
    `
  }

  protected renderBlobBody(body: Blob): TemplateResult {
    const { renderTokens, raw } = this
    if (renderTokens && raw) {
      return html` <div class="raw-panel padded-panel">${renderTokens}</div> `
    }
    return html` <div class="raw-panel padded-panel">File data of ${body.size} bytes.</div> `
  }

  protected renderBufferBody(body: ArrayBuffer): TemplateResult {
    const { renderTokens, raw } = this
    if (renderTokens && raw) {
      return html` <div class="raw-panel padded-panel">${renderTokens}</div> `
    }
    return html` <div class="raw-panel padded-panel">Binary data of ${body.byteLength} bytes.</div> `
  }

  protected renderPdfBody(): TemplateResult {
    return html`
      <div class="content-info pdf padded-panel">
        <p>The response is a <b>PDF</b> data.</p>
        <p>Save the file to preview its contents.</p>
      </div>
    `
  }

  protected renderImageSvgBody(): TemplateResult {
    const { renderBody, raw } = this
    if (raw) {
      return this.renderStringBody()
    }
    const div = window.document.createElement('div')
    div.innerHTML = String(renderBody)
    const svgEl = div.firstElementChild
    if (!svgEl) {
      return this.renderError('Invalid SVG response.')
    }
    const all = Array.from(svgEl.querySelectorAll('*'))
    all.forEach((node) => {
      if (!node.attributes) {
        return
      }
      const remove = Array.from(node.attributes).filter(
        (attr) => attr.name.startsWith('on') || ['xlink:href'].includes(attr.name)
      )
      remove.forEach(({ name }) => node.removeAttribute(name))
    })
    const scripts = Array.from(svgEl.getElementsByTagName('script'))
    scripts.forEach((node) => (node.parentNode as Element).removeChild(node))
    return html` <div class="image-container">${unsafeSVG(svgEl.outerHTML)}</div> `
  }

  protected renderImageBinaryBody(): TemplateResult {
    const { renderTokens } = this
    if (!renderTokens) {
      return this.renderNoData('No image data')
    }
    return html` <div class="image-container">
      <img class="img-preview" src="${renderTokens}" alt="" @error="${this.handleImageError}" />
    </div>`
  }

  protected renderImageError(): TemplateResult {
    const { renderTokens } = this
    if (!renderTokens) {
      return this.renderNoData('No image data')
    }
    return html` <div class="error-container padded-panel"><b>The image URL is invalid</b>: "${renderTokens}"</div>`
  }

  protected renderNoData(label = 'Unsupported view.'): TemplateResult {
    return html`<p class="no-info">${label}</p>`
  }

  protected renderError(message: string): TemplateResult {
    return html`<p class="error">${message}</p>`
  }

  protected renderUrlEncodedBody(): TemplateResult {
    const { renderBody, raw } = this
    if (raw) {
      return this.renderStringBody()
    }
    let params: URLSearchParams
    try {
      params = new URLSearchParams(renderBody as string)
    } catch {
      return this.renderError(`Unable to parse URL encoded data.`)
    }
    const content: TemplateResult[] = []
    for (const [name, value] of params) {
      content.push(html`
        <tr>
          <td class="name">${name}</td>
          <td class="value">${value}</td>
        </tr>
      `)
    }
    return html`
      <table class="url-params">
        <caption>
          URL encoded parameters
        </caption>
        <thead>
          <tr>
            <th scope="col" class="name">Name</th>
            <th scope="col" class="value">Value</th>
          </tr>
        </thead>
        <tbody>
          ${content}
        </tbody>
      </table>
    `
  }
}
