import { html, TemplateResult, nothing } from 'lit'
import { property } from 'lit/decorators.js'
import {
  type IBlobMeta,
  type IFileMeta,
  type IMultipartBody,
  type ISafePayload,
  PayloadSerializer,
} from '@api-client/core/lib/transformers/PayloadSerializer.js'
import { queryRequestHeaders } from '@api-client/core/lib/definitions/HttpDefinitions.js'
import { live } from 'lit/directives/live.js'
import SwitchElement from '../../md/switch/internals/SwitchElement.js'
import Input from '../../md/input/Input.js'
import ApiElement from '../ApiElement.js'
import '../../md/icons/ui-icon.js'
import '../../md/switch/ui-switch.js'
import '../../md/icon-button/ui-icon-button.js'
import '../../md/button/ui-button.js'

let hasSupport: boolean
try {
  const fd = new FormData()
  fd.append('test', new Blob(['.'], { type: 'image/jpg' }), 'test.jpg')
  hasSupport = 'entries' in fd
} catch {
  hasSupport = false
}
export const hasFormDataSupport = hasSupport

function normalizePartValue(item: IMultipartBody): void {
  if (typeof item.value === 'string') {
    const oldValue = item.value
    item.value = {
      type: 'string',
      data: oldValue,
    }
  }
}

let mimeSuggestions: string[] = []

;((): void => {
  const items = queryRequestHeaders('content-type')
  const item = items.find((i) => i.key.toLowerCase() === 'content-type')
  if (!item || !item.autocomplete) {
    return
  }
  mimeSuggestions = item.autocomplete
})()

/**
 * Multipart payload editor.
 *
 * The element internally uses a generated from a FormData model. It is API Client's `IMultipartBody` array.
 * The FormData is generated on-fly when accessing the `value` getter. It also regenerates the model when
 * the `value` property change. Because of that you should never set the value on the editor but only manipulate the
 * `model` property.
 */
export default class BodyMultipartEditor extends ApiElement {
  /**
   * When set the editor is in read only mode.
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) accessor readOnly = false

  /**
   * When set all controls are disabled in the form
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) accessor disabled = false

  /**
   * @attribute
   */
  @property({ type: Boolean }) accessor allowFormInfo: boolean | undefined

  /**
   * The value of this form.
   * When reading the `value` it generated the FormData from the model values.
   * When setting this property is regenerates the `model` to the value in the FormData.
   * When setting to anything other than the `FormData` it sets the `model` to an empty array.
   */
  get value(): FormData {
    return this.toFormData()
  }

  @property({ type: Object })
  set value(value: FormData) {
    this.updateModel(value)
  }

  /**
   * The model used by the external application
   */
  protected modelInternal: IMultipartBody[] = []

  /**
   * Computed data model for the view.
   * Don't set both `value` and `model`. If the model exists then
   * set only it or otherwise the `value` setter override the model.
   */
  get model(): IMultipartBody[] {
    return this.modelInternal
  }

  @property({ type: Array })
  set model(value: IMultipartBody[] | undefined) {
    const old = this.modelInternal
    if (old === value) {
      return
    }
    if (Array.isArray(value)) {
      this.modelInternal = value
    } else {
      this.modelInternal = []
    }
    this.requestUpdate('model', old)
    if (value) {
      this.upgradeLegacy(value)
    }
  }

  /**
   * @returns The FormData from the current model.
   */
  toFormData(): FormData {
    const validItems = this.modelInternal.filter((item) => {
      const { name, value } = item
      if (!name) {
        return false
      }
      if (typeof value === 'string') {
        return true
      }
      if (value.type === 'string') {
        return true
      }
      if (value.type === 'blob' || value.type === 'file') {
        return !!value.data && !!value.data.length
      }
      return true
    })
    return PayloadSerializer.deserializeFormData(validItems)
  }

  /**
   * Makes an attempt to upgrade old definitions of the `IMultipartBody`.
   * If this fails, the form will show error instead of part field.
   * @param value The current model.
   */
  protected async upgradeLegacy(value: IMultipartBody[]): Promise<void> {
    const ps: Promise<void>[] = []
    for (const part of value) {
      if (part.isFile === undefined) {
        continue
      }
      if (part.isFile === false && !part.type) {
        delete part.isFile
        const oldValue = (part.value || '') as string
        part.value = {
          type: 'string',
          data: oldValue,
        }
      } else if (part.isFile === false && part.type) {
        ps.push(this.upgradeLegacyBlob(part))
      } else {
        ps.push(this.upgradeLegacyFile(part))
      }
    }
    if (ps.length) {
      await Promise.allSettled(ps)
      this.requestUpdate()
      this.notifyChange()
    }
  }

  protected async upgradeLegacyBlob(part: IMultipartBody): Promise<void> {
    if (typeof part.value !== 'string') {
      return
    }
    const readBlob = PayloadSerializer.deserializeBlobLegacy(part.value as string)
    if (!readBlob) {
      return
    }
    const contents = await readBlob.text()
    part.blobText = contents || ''
    const blob = new Blob([part.blobText as string], { type: part.type })
    part.value = await PayloadSerializer.stringifyBlob(blob)
    delete part.type
    delete part.isFile
  }

  protected async upgradeLegacyFile(part: IMultipartBody): Promise<void> {
    if (typeof part.value !== 'string') {
      return
    }
    const readBlob = PayloadSerializer.deserializeBlobLegacy(part.value as string)
    if (!readBlob) {
      return
    }
    const fname = part.fileName || 'unknown-file.txt'
    const file = new File([readBlob], fname, { type: readBlob.type })
    part.value = await PayloadSerializer.stringifyFile(file)
    delete part.fileName
    delete part.isFile
  }

  protected async updateModel(value?: unknown): Promise<void> {
    if (!value || !(value instanceof FormData)) {
      this.model = []
      setTimeout(() => {
        this.dispatchEvent(new CustomEvent('modelupdate', { bubbles: false, composed: false }))
      })
      return
    }
    const payload = await PayloadSerializer.stringifyFormData(value as FormData)
    this.model = payload.data as IMultipartBody[]
    this.dispatchEvent(new CustomEvent('modelupdate', { bubbles: false, composed: false }))
  }

  /**
   * Adds an instance of a file to the form data
   */
  async addFile(file: File): Promise<void> {
    if (this.readOnly || this.disabled) {
      return
    }
    const { model } = this
    const entry = await PayloadSerializer.serializeFormDataEntry(file.name, file)
    model.push(entry)
    this.notifyChange()
    this.requestUpdate()
  }

  /**
   * Removes all parameters.
   */
  clear(): void {
    this.model = []
    this.notifyChange()
  }

  /**
   * Adds an empty text part item to the form.
   */
  addEmptyText(): void {
    if (this.readOnly || this.disabled) {
      return
    }
    const { model } = this
    const obj: IMultipartBody = {
      name: '',
      value: {
        data: '',
        type: 'string',
      },
      enabled: true,
    }
    model.push(obj)
    this.requestUpdate()
  }

  /**
   * Adds an empty file part item to the form.
   */
  addEmptyFile(): void {
    if (this.readOnly || this.disabled) {
      return
    }
    const { model } = this
    const obj: IMultipartBody = {
      name: '',
      value: {
        data: [],
        type: 'file',
      },
      enabled: true,
    }
    model.push(obj)
    this.requestUpdate()
  }

  /**
   * Removes a parameter from an index.
   */
  removeProperty(index: number): void {
    const items = this.modelInternal
    items.splice(index, 1)
    this.requestUpdate()
    this.notifyChange()
  }

  /**
   * Toggles enabled state of a parameter.
   */
  toggleProperty(index: number, enabled: boolean): void {
    const items = this.modelInternal
    const item = items[index]
    item.enabled = enabled
    this.notifyChange()
  }

  updatePartName(index: number, value: string): void {
    const item = this.modelInternal[index]
    const old = item.name
    if (old === value) {
      return
    }
    item.name = value
    this.notifyChange()
  }

  updateTextPartValue(index: number, value: string): void {
    const item = this.modelInternal[index]
    normalizePartValue(item)
    const typedValue = item.value as ISafePayload
    if (typedValue.data === value) {
      return
    }
    typedValue.data = value
    this.notifyChange()
  }

  async updateBlobPartValue(index: number, value: string): Promise<void> {
    const item = this.modelInternal[index]
    normalizePartValue(item)
    const typedValue = item.value as ISafePayload
    if (typedValue.data === value) {
      return
    }
    item.blobText = value
    const blob = new Blob([value], { type: typedValue.meta?.mime || 'text/plain' })
    item.value = await PayloadSerializer.stringifyBlob(blob)
    this.notifyChange()
  }

  async updateFileValue(index: number, file: File): Promise<void> {
    const item = this.modelInternal[index]
    if (!item || !file) {
      return
    }
    item.value = await PayloadSerializer.stringifyFile(file)
    this.notifyChange()
    this.requestUpdate()
  }

  async changePartMime(index: number, value: string): Promise<void> {
    const item = this.modelInternal[index]
    if (!item) {
      return
    }
    normalizePartValue(item)
    // change the type from text to blob, if needed
    const typedValue = item.value as ISafePayload

    if (typedValue.type === 'string') {
      item.blobText = typedValue.data as string
    } else if (!item.blobText && typedValue.data) {
      const restored = PayloadSerializer.deserializeBlob(typedValue) as Blob
      item.blobText = await restored.text()
    }
    const blob = new Blob([item.blobText || ''], { type: value })
    item.value = await PayloadSerializer.stringifyBlob(blob)
    this.notifyChange()
  }

  protected handleRemove(e: Event): void {
    const node = e.currentTarget as HTMLElement
    const index = Number(node.dataset.index)
    if (Number.isNaN(index)) {
      return
    }
    this.removeProperty(index)
  }

  protected handleToggle(e: Event): void {
    const node = e.currentTarget as SwitchElement
    const index = Number(node.dataset.index)
    if (Number.isNaN(index)) {
      return
    }
    this.toggleProperty(index, !!node.checked)
  }

  protected handleFilePick(e: Event): void {
    const node = e.target as HTMLElement
    const input = node.nextElementSibling as HTMLInputElement
    input.click()
  }

  protected handleFilePartValue(e: Event): void {
    e.stopPropagation()
    const input = e.target as HTMLInputElement
    if (!input.files) {
      return
    }
    const index = Number(input.dataset.index)
    this.updateFileValue(index, input.files[0])
  }

  protected handleNameChange(e: Event): void {
    const input = e.target as Input
    const index = Number(input.dataset.index)
    if (Number.isNaN(index)) {
      return
    }
    this.updatePartName(index, input.value)
    input.checkValidity()
  }

  protected handleValueChange(e: Event): void {
    const input = e.target as Input
    const index = Number(input.dataset.index)
    if (Number.isNaN(index)) {
      return
    }
    this.updateTextPartValue(index, input.value)
    input.checkValidity()
  }

  protected handleBlobValueChange(e: Event): void {
    const input = e.target as Input
    const index = Number(input.dataset.index)
    if (Number.isNaN(index)) {
      return
    }
    this.updateBlobPartValue(index, input.value)
    input.checkValidity()
  }

  protected handleMimeChange(e: Event): void {
    const input = e.target as Input
    const index = Number(input.dataset.index)
    if (Number.isNaN(index)) {
      return
    }
    this.changePartMime(index, input.value)
    input.checkValidity()
  }

  override render(): TemplateResult {
    if (!hasSupport) {
      return this.renderNoSupport()
    }
    return html` ${this.renderList()} ${this.renderMimeList()} ${this.renderFormInfo()} `
  }

  protected renderFormInfo(): TemplateResult | typeof nothing {
    if (!this.allowFormInfo) {
      return nothing
    }
    return html`
      <p class="form-info">
        Note that the content-type header will be added when the request is sent. We encourage you to use variables to
        insert sensitive data.
      </p>
    `
  }

  protected renderList(): TemplateResult {
    const items = this.model
    if (!Array.isArray(items) || !items.length) {
      return this.renderEmptyMessage()
    }
    return this.renderParams(items)
  }

  protected renderNoSupport(): TemplateResult {
    return html` <div class="empty-message">Your browser does not support this editor</div> `
  }

  protected renderEmptyMessage(): TemplateResult {
    return html` <div class="empty-message">No parameters added</div> `
  }

  protected renderParams(items: IMultipartBody[]): TemplateResult {
    return html` ${items.map((item, index) => this.renderParam(item, index))} `
  }

  protected renderToggleButton(part: IMultipartBody, index: number): TemplateResult {
    return html`
      <ui-switch
        .checked="${!!part.enabled}"
        @change="${this.handleToggle}"
        aria-label="Toggle this parameter"
        data-index="${index}"
        ?disabled="${this.readOnly}"
      ></ui-switch>
    `
  }

  protected renderDeleteParameter(index: number): TemplateResult {
    return html`
      <ui-icon-button
        aria-label="Delete this parameter"
        data-index="${index}"
        @click="${this.handleRemove}"
        ?disabled="${this.readOnly}"
        class="delete-button"
      >
        <ui-icon icon="deleteOutline" role="presentation"></ui-icon>
      </ui-icon-button>
    `
  }

  protected renderNameField(part: IMultipartBody, index: number): TemplateResult {
    return html`
      <div class="input-wrapper name-input">
        <div class="error" role="presentation">Name is required</div>
        <div class="state"></div>
        <input
          type="text"
          name="name"
          required
          autocomplete="off"
          .value="${live(part.name)}"
          data-index="${index}"
          ?readOnly="${this.readOnly}"
          aria-label="Part name value"
          placeholder="Part name"
          @change="${this.handleNameChange}"
        />
      </div>
    `
  }

  protected renderValueField(part: IMultipartBody, index: number): TemplateResult {
    const value = ((part.value as ISafePayload).data as string) || ''
    return html`
      <div class="input-wrapper value-input">
        <div class="error" role="presentation">Value is required</div>
        <div class="state"></div>
        <input
          type="text"
          name="value"
          required
          .value="${live(value)}"
          data-index="${index}"
          ?readOnly="${this.readOnly}"
          aria-label="Text part value"
          placeholder="Part text value"
          @change="${this.handleValueChange}"
        />
      </div>
    `
  }

  protected renderBlobValueField(part: IMultipartBody, index: number): TemplateResult {
    const { blobText } = part
    return html`
      <div class="input-wrapper value-input">
        <div class="error" role="presentation">Value is required</div>
        <div class="state"></div>
        <input
          type="text"
          name="value"
          required
          .value="${live(blobText || '')}"
          data-index="${index}"
          ?readOnly="${this.readOnly}"
          aria-label="Text part value"
          placeholder="Part text value"
          @change="${this.handleBlobValueChange}"
        />
      </div>
    `
  }

  protected renderMimeField(index: number, value?: string): TemplateResult {
    return html`
      <div class="input-wrapper mime-input">
        <div class="error" role="presentation">Invalid character</div>
        <div class="state"></div>
        <input
          type="text"
          name="value"
          .value="${live(value || '')}"
          data-index="${index}"
          ?readOnly="${this.readOnly}"
          aria-label="Text part mime - optional value"
          placeholder="Part mime (optional)"
          @change="${this.handleMimeChange}"
          list="mime-list"
        />
      </div>
    `
  }

  protected renderParam(part: IMultipartBody, index: number): TemplateResult {
    if (typeof part.isFile === 'boolean') {
      // When this is still set it means that when setting the model something went wrong.
      // This also mean we can't recover from here so the only option is to remove the part.
      return this.renderIncompatiblePart(index)
    }
    normalizePartValue(part)
    const value = part.value as ISafePayload
    if (value.type === 'string') {
      return this.renderTextPart(part, index)
    }
    if (value.type === 'blob') {
      return this.renderBlobPart(part, index)
    }
    return this.renderFilePart(part, index)
  }

  protected renderTextPart(part: IMultipartBody, index: number): TemplateResult {
    return html`
      <div class="param-row">
        ${this.renderToggleButton(part, index)} ${this.renderNameField(part, index)}
        ${this.renderValueField(part, index)} ${this.renderMimeField(index)} ${this.renderDeleteParameter(index)}
      </div>
    `
  }

  protected renderBlobPart(part: IMultipartBody, index: number): TemplateResult {
    const item = part.value as ISafePayload
    const meta = item && (item.meta as IBlobMeta | undefined)
    return html`
      <div class="param-row">
        ${this.renderToggleButton(part, index)} ${this.renderNameField(part, index)}
        ${this.renderBlobValueField(part, index)} ${this.renderMimeField(index, meta && meta.mime)}
        ${this.renderDeleteParameter(index)}
      </div>
    `
  }

  protected renderFilePart(part: IMultipartBody, index: number): TemplateResult {
    return html`
      <div class="param-row">
        ${this.renderToggleButton(part, index)} ${this.renderNameField(part, index)}
        ${this.renderFileField(part, index)} ${this.renderDeleteParameter(index)}
      </div>
    `
  }

  protected renderFileField(part: IMultipartBody, index: number): TemplateResult {
    const { readOnly } = this
    const item = part.value as ISafePayload
    const meta = item && (item.meta as IFileMeta | undefined)
    return html`
      <div class="input-wrapper file-input">
        <ui-button color="text" @click="${this.handleFilePick}" ?disabled="${readOnly}" class="choose-button"
          >Choose file</ui-button
        >
        <input type="file" hidden data-index="${index}" @change="${this.handleFilePartValue}" />
        ${meta ? html`<span class="file-info">${meta.name} (${meta.mime}), ${item.data.length} bytes</span>` : ''}
      </div>
    `
  }

  protected renderIncompatiblePart(index: number): TemplateResult {
    return html`
      <div class="param-row">
        <p class="incompatible-part">This part cannot be rendered. Please, remove this part.</p>
        ${this.renderDeleteParameter(index)}
      </div>
    `
  }

  protected renderMimeList(): TemplateResult {
    return html`
      <datalist id="mime-list">${mimeSuggestions.map((i) => html`<option value="${i}"></option>`)}</datalist>
    `
  }
}
