import { html, TemplateResult, PropertyValues, nothing } from 'lit'
import { property, query } from 'lit/decorators.js'
import type { IBodyMetaModel, IRawBody, IUrlEncodedBodyMeta } from '@api-client/core/models/RequestUiMeta.js'
import {
  type IMultipartBody,
  type ISafePayload,
  PayloadSerializer,
} from '@api-client/core/lib/transformers/PayloadSerializer.js'
import type { IProperty } from '@api-client/core/models/Property.js'
import { ifProperty } from '../../directives/if-property.js'
import BodyUrlEncodedEditor from './BodyUrlEncodedEditor.js'
import BodyTextEditor from './BodyTextEditor.js'
import BodyMultipartEditor from './BodyMultipartEditor.js'
import { Events } from '../../events/Events.js'
import ApiElement from '../ApiElement.js'
import CheckboxElement from '../../md/checkbox/internals/CheckboxElement.js'
import '../../md/icons/ui-icon.js'
import '../../define/http/http-body-urlencoded-editor.js'
import '../../define/http/http-body-multipart-editor.js'
import '../../define/http/http-body-text-editor.js'
import '../../md/button/ui-button.js'
import '../../md/dropdown-list/ui-dropdown-list.js'
import '../../md/list/ui-list.js'
import '../../md/list/ui-list-item.js'
import '../../md/divider/ui-divider.js'
import '../../md/checkbox/ui-checkbox.js'

export type AllowedEditor = 'raw' | 'urlEncode' | 'multipart' | 'file'

export interface EditorType {
  id: AllowedEditor
  label: string
  title: string
}

export const editorTypes: readonly EditorType[] = Object.freeze([
  {
    id: 'raw',
    label: 'Text editor',
    title: 'Opens a source editor with syntax highlighting',
  },
  {
    id: 'urlEncode',
    label: 'Form URL encoded',
    title: 'Opens an editor specialized with URL encoded data types',
  },
  {
    id: 'multipart',
    label: 'Multipart form data',
    title: 'Opens an editor specialized with multipart data',
  },
  {
    id: 'file',
    label: 'A file',
    title: 'Allows to choose any binary data',
  },
])

const monacoContentTypes: Record<string, string> = {
  'application/json': 'JSON',
  'application/xml': 'XML',
  'text/html': 'HTML',
  'text/css': 'CSS',
  '': 'Inherited from headers',
}

/**
 * @fires change When the value and the model change
 */
export default class BodyEditor extends ApiElement {
  /**
   * The currently rendered editor.
   * @attribute
   */
  @property({ type: String }) accessor selected: AllowedEditor = 'raw'

  protected valueInternal: unknown = ''

  /**
   * The HTTP body.
   *
   * Depending of current editor selection the type can vary.
   */
  get value(): unknown {
    return this.valueInternal
  }

  @property({ attribute: false })
  set value(value: unknown) {
    const old = this.valueInternal
    if (old === value) {
      return
    }
    // this is to be able to set a private filed value from
    // within the element
    this.valueInternal = value
    this.requestUpdate()
  }

  protected modelInternal: IBodyMetaModel[] | undefined

  /**
   * Previously generated by this editor metadata model.
   * Used internally to set the UI.
   */
  get model(): IBodyMetaModel[] | undefined {
    return this.modelInternal
  }

  @property({ type: Array })
  set model(value: IBodyMetaModel[] | undefined) {
    const old = this.modelInternal
    if (old === value) {
      return
    }
    // this is to be able to set a private filed value from
    // within the element
    this.modelInternal = value
    this.requestUpdate()
    this.modelChanged()
  }

  /**
   * 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

  /**
   * When set it automatically encodes and decodes values.
   * @attribute
   */
  @property({ type: Boolean, reflect: true }) accessor autoEncode = false

  /**
   * @returns True when the current value is a file (or blob).
   */
  get hasFile(): boolean {
    const { value } = this
    return value instanceof Blob
  }

  /**
   * The current request content type. It is passed to the `raw` editor
   * to detect current language.
   * @attribute
   */
  @property({ type: String, reflect: true }) accessor contentType: string | undefined

  protected invalidMimeMessage?: number

  protected invalidMimeValue?: boolean

  protected mimeValue?: string

  @query('http-body-urlencoded-editor') protected accessor urlEncodedEditor: BodyUrlEncodedEditor | null = null

  @query('http-body-multipart-editor') protected accessor multipartEditor: BodyMultipartEditor | null = null

  constructor() {
    super()
    this.addEventListener('drop', this.handlerDrop.bind(this))
    this.addEventListener('dragover', this.handleDragOver.bind(this))
  }

  protected override update(cp: PropertyValues<this>): void {
    if (cp.has('contentType') || cp.has('selected')) {
      this.analyzeContentType()
    }
    super.update(cp)
  }

  /**
   * This must be called only when the `model` property change from the
   * outside. It restores values depending on the selected editor.
   */
  protected modelChanged(): void {
    const { selected, model } = this
    if (!model) {
      return
    }
    if (selected === 'file') {
      this.restoreFileModel()
    }
    const info = this.readMetaModel('urlEncode') as IUrlEncodedBodyMeta[] | undefined
    if (Array.isArray(info) && info[0]) {
      if (typeof info[0].autoEncode === 'boolean') {
        this.autoEncode = info[0].autoEncode
      }
    }
  }

  /**
   * Restores file value from the model, if exists
   */
  protected restoreFileModel(): void {
    const fileModel = this.readMetaModel('file') as ISafePayload[] | undefined
    if (!Array.isArray(fileModel) || !fileModel.length) {
      this.valueInternal = ''
      return
    }
    const [item] = fileModel
    if (!item || !item.data) {
      this.valueInternal = ''
      return
    }
    const file = PayloadSerializer.deserializeFile(item)
    this.valueInternal = file
  }

  protected handleTypeChange(e: CustomEvent): void {
    const target = e.detail.item as HTMLElement | undefined
    if (!target) {
      return
    }
    const selected = target.dataset.type as AllowedEditor
    this.changeEditor(selected)
  }

  /**
   * A handler for the editor selection. It activates an editor, if necessary.
   */
  protected async changeEditor(id: AllowedEditor): Promise<void> {
    if (this.selected === id) {
      return
    }
    this.selected = id
    this.analyzeContentType()
    if (id === 'raw') {
      const model = this.readMetaModel('raw') as IRawBody[]
      let value = ''
      if (Array.isArray(model) && model.length) {
        value = model[0].value
      }
      this.value = value
      this.notifyChange()
    }
    this.requestUpdate()
    await this.updateComplete
    // this.notifyResize();
    if (id === 'file') {
      this.restoreFileModel()
      this.notifyChange()
    } else if (id === 'urlEncode') {
      const editor = (this.shadowRoot as ShadowRoot).querySelector('http-body-urlencoded-editor')
      if (!editor) {
        throw new Error(`Invalid state. The <http-body-urlencoded-editor> element was removed from the DOM.`)
      }
      this.value = editor.value
      this.notifyChange()
    } else if (id === 'multipart') {
      const editor = (this.shadowRoot as ShadowRoot).querySelector('http-body-multipart-editor')
      if (!editor) {
        throw new Error(`Invalid state. The <http-body-multipart-editor> element was removed from the DOM.`)
      }
      this.value = editor.value
      this.notifyChange()
    }
    this.dispatchEvent(new Event('select'))
  }

  /**
   * A handler for the file pick button click.
   * Activates the file input.
   */
  protected handlePickFile(): void {
    const input = (this.shadowRoot as ShadowRoot).querySelector('.binary-hidden') as HTMLInputElement
    input.click()
  }

  /**
   * A handler for the file file selection in the file input.
   * Sets the value to the file.
   */
  protected async handleFileChange(e: Event): Promise<void> {
    const input = e.target as HTMLInputElement
    const { files } = input
    if (!files) {
      return
    }
    const file = files[0]
    if (!file) {
      return
    }
    this.valueInternal = file
    const payload = await PayloadSerializer.stringifyFile(file)
    const model = [payload]
    this.setMetaModel('file', model)
    this.notifyChange()
    this.requestUpdate()
  }

  /**
   * Clears the current value
   */
  protected handleClearFile(): void {
    this.value = ''
    this.setMetaModel('file', [])
    this.notifyChange()
  }

  /**
   * Reads editor view model
   *
   * @param editor The editor id
   * @returns The view model
   */
  protected readMetaModel(
    editor: string
  ): (ISafePayload | IProperty | IMultipartBody | IRawBody | IUrlEncodedBodyMeta)[] | undefined {
    const meta = this.model
    if (!meta || !Array.isArray(meta)) {
      return undefined
    }
    const item = meta.find((model) => model.type === editor)
    if (!item) {
      return undefined
    }
    if (Array.isArray(item.viewModel)) {
      return item.viewModel
    }
    return undefined
  }

  /**
   * Sets editor view model on the local model
   * @param editor The editor id
   * @param model The view model
   */
  protected setMetaModel(
    editor: AllowedEditor,
    model: (ISafePayload | IProperty | IMultipartBody | IRawBody | IUrlEncodedBodyMeta)[]
  ): void {
    if (!Array.isArray(this.modelInternal)) {
      this.modelInternal = []
    }
    const meta = this.model as IBodyMetaModel[]
    const index = meta.findIndex((item) => item.type === editor)
    if (index === -1) {
      meta.push({ type: editor, viewModel: model })
    } else {
      meta[index].viewModel = model
    }
  }

  /**
   * A handler for the change event dispatched by the
   * `urlEncode` editor.
   * Updated the local value, model, and notifies the change.
   */
  protected handleUrlEncodeChange(e: Event): void {
    const editor = e.target as BodyUrlEncodedEditor
    this.updateUrlEncodedValue(editor)
  }

  protected updateUrlEncodedValue(editor: BodyUrlEncodedEditor): void {
    const { value, model } = editor
    this.valueInternal = value
    const info: IUrlEncodedBodyMeta = {
      model,
      autoEncode: this.autoEncode,
    }
    this.setMetaModel('urlEncode', [info])
    this.notifyChange()
    // do not request update here.
  }

  /**
   * A handler for the change event dispatched by the `raw` editor.
   * Updated the local value, model, and notifies the change.
   */
  protected handleRawChange(e: Event): void {
    const editor = e.target as BodyTextEditor
    const { value } = editor
    this.valueInternal = value
    const model: IRawBody[] = [{ value: value as string }]
    this.setMetaModel('raw', model)
    this.notifyChange()
  }

  /**
   * A handler for the change event dispatched by the
   * `multipart` editor.
   * Updated the local value, model, and notifies the change.
   */
  protected handleMultipartChange(e: Event): void {
    const editor = e.target as BodyMultipartEditor
    const { value, model } = editor
    this.valueInternal = value
    this.setMetaModel('multipart', model)
    this.notifyChange()
  }

  /**
   * Checks whether the current content type header value matches the selected editor.
   * If not it renders a warning message.
   */
  protected analyzeContentType(): void {
    const mime = this.contentType
    if (!mime || typeof mime !== 'string') {
      this.invalidMimeValue = false
      this.mimeValue = undefined
      return
    }
    let ct = mime
    const semicolon = ct.indexOf(';')
    if (semicolon !== -1) {
      ct = ct.substring(0, semicolon)
    }
    this.mimeValue = ct
    if (!ct) {
      this.invalidMimeValue = false
      return
    }
    const { selected } = this
    if (selected === 'multipart') {
      if (ct !== 'multipart/form-data') {
        this.invalidMimeValue = true
        this.invalidMimeMessage = 1
      } else {
        this.invalidMimeValue = false
      }
    } else if (selected === 'urlEncode') {
      if (ct !== 'application/x-www-form-urlencoded') {
        this.invalidMimeValue = true
        this.invalidMimeMessage = 2
      } else {
        this.invalidMimeValue = false
      }
    } else {
      this.invalidMimeValue = false
    }
  }

  /**
   * Automatically fixes content type problem.
   */
  protected autoFixMime(): void {
    const { selected } = this
    let updated = ''
    if (selected === 'urlEncode') {
      updated = 'application/x-www-form-urlencoded'
    } else if (selected === 'multipart') {
      updated = 'multipart/form-data'
    }
    Events.Http.Request.State.contentTypeChange(updated, this)
  }

  /**
   * A handler for the mime type selection.
   */
  protected handleMimeTypeSelect(e: CustomEvent): void {
    const target = e.detail.item as HTMLElement | undefined
    if (!target) {
      return
    }
    const { type = '' } = target.dataset
    if (this.mimeValue === type) {
      return
    }
    this.contentType = type
    Events.Http.Request.State.contentTypeChange(type, this)
  }

  async handlerDrop(e: DragEvent): Promise<void> {
    e.preventDefault()
    const { dataTransfer } = e
    if (!dataTransfer) {
      return
    }
    const { files } = dataTransfer
    if (!files.length) {
      return
    }
    const file = files[0]
    const { selected } = this
    if (selected === 'file') {
      this.value = file
    } else if (selected === 'multipart') {
      const editor = (this.shadowRoot as ShadowRoot).querySelector('http-body-multipart-editor')
      if (!editor) {
        throw new Error(`Invalid state. The <http-body-multipart-editor> element was removed from the DOM.`)
      }
      if (!e.ctrlKey && !e.metaKey) {
        editor.value = new FormData()
      }
      const ps = Array.from(files).map((item) => editor.addFile(item))
      await Promise.all(ps)
    } else {
      const payload = await PayloadSerializer.stringifyBlob(file)
      this.value = payload.data
      const { type } = file
      if (type) {
        Events.Http.Request.State.contentTypeChange(type, this)
      }
    }
    this.notifyChange()
  }

  protected handleDragOver(e: DragEvent): void {
    e.preventDefault()
  }

  protected handleAddUrlEncode(): void {
    const editor = this.urlEncodedEditor
    if (editor) {
      editor.addParameter()
    }
  }

  protected handleUrlEncode(): void {
    const editor = this.urlEncodedEditor
    if (editor) {
      editor.encodeParameters()
    }
  }

  protected handleUrlDecode(): void {
    const editor = this.urlEncodedEditor
    if (editor) {
      editor.decodeParameters()
    }
  }

  protected handleUrlEncodeAutoChange(e: Event): void {
    const input = e.target as CheckboxElement
    this.autoEncode = !!input.checked
    const editor = this.urlEncodedEditor
    if (editor) {
      this.updateUrlEncodedValue(editor)
    }
  }

  protected handleMultipartAddText(): void {
    const editor = this.multipartEditor
    if (editor) {
      editor.addEmptyText()
    }
  }

  protected handleMultipartAddFile(): void {
    const editor = this.multipartEditor
    if (editor) {
      editor.addEmptyFile()
    }
  }

  override render(): TemplateResult {
    return html`
      <div class="actions">${this.renderBodySelector()} ${this.renderMainActions()}</div>
      ${this.renderInvalidMime()}
      <div class="container">${this.renderEditor()}</div>
    `
  }

  /**
   * @returns The template for the dropdown menu for the editor type
   */
  protected renderBodySelector(): TemplateResult {
    const { selected } = this
    const selectedItem = editorTypes.find((i) => i.id === selected)
    const label = (selectedItem && selectedItem.label) || 'Select editor'
    return html`
      <ui-dropdown-list
        verticalAlign="top"
        @select="${this.handleTypeChange}"
        class="type-dropdown"
        closeOnOutsideClick
      >
        <ui-button color="tonal" title="Select editor type" aria-label="Opens a menu to select editor type">
          <ui-icon icon="arrowDropDown" slot="icon" role="presentation"></ui-icon>
          ${label}
        </ui-button>
        <ui-list slot="dropdown" role="menu"> ${this.renderBodyTypeOptions()} </ui-list>
      </ui-dropdown-list>
    `
  }

  protected renderBodyTypeOptions(): TemplateResult {
    return html`
      ${editorTypes.map(
        (info) => html`
          <ui-list-item role="menuitem" data-type="${info.id}" title="${info.title}"> ${info.label} </ui-list-item>
        `
      )}
    `
  }

  /**
   * @returns The template for the main editor actions.
   */
  protected renderMainActions(): TemplateResult | typeof nothing {
    const { selected } = this
    if (selected === 'raw') {
      return this.renderTextEditorActions()
    }
    if (selected === 'urlEncode') {
      return this.renderUrlEncodeEditorActions()
    }
    if (selected === 'multipart') {
      return this.renderMultipartActions()
    }
    return nothing
  }

  protected renderTextEditorActions(): TemplateResult {
    const label = monacoContentTypes[this.mimeValue || ''] || monacoContentTypes['']
    return html`
      <ui-dropdown-list
        verticalAlign="top"
        @select="${this.handleMimeTypeSelect}"
        class="mime-dropdown"
        closeOnOutsideClick
      >
        <ui-button
          color="tonal"
          title="Select media type. This overrides the content-type header"
          aria-label="Editor media type"
        >
          <ui-icon icon="arrowDropDown" slot="icon"></ui-icon>
          ${label}
        </ui-button>
        <ui-list slot="dropdown" role="menu"> ${this.renderMonacoOptions()} </ui-list>
      </ui-dropdown-list>
    `
  }

  protected renderMonacoOptions(): TemplateResult[] {
    return Object.keys(monacoContentTypes).map(
      (key) => html`
        ${key === '' ? html`<ui-divider></ui-divider>` : nothing}
        <ui-list-item role="menuitem" data-type="${key}"> ${monacoContentTypes[key]} </ui-list-item>
      `
    )
  }

  protected renderUrlEncodeEditorActions(): TemplateResult {
    const { autoEncode } = this
    return html`
      <ui-button
        color="tonal"
        aria-label="Adds a new parameter"
        title="Adds a new parameter"
        @click="${this.handleAddUrlEncode}"
      >
        <ui-icon slot="icon" icon="add" role="presentation"></ui-icon>
        Add
      </ui-button>
      <ui-button
        color="outlined"
        title="URL encodes parameters in the editor"
        aria-label="URL encodes parameters in the editor"
        @click="${this.handleUrlEncode}"
        ?disabled="${autoEncode}"
      >
        URL encode
      </ui-button>
      <ui-button
        color="outlined"
        title="URL decodes parameters in the editor"
        aria-label="URL decodes parameters in the editor"
        @click="${this.handleUrlDecode}"
        ?disabled="${autoEncode}"
      >
        URL decode
      </ui-button>
      <label title="When set it automatically encodes the values before sending the request" class="label-medium">
        <ui-checkbox .checked="${autoEncode}" @change="${this.handleUrlEncodeAutoChange}"></ui-checkbox>
        Encode on send
      </label>
    `
  }

  protected renderMultipartActions(): TemplateResult {
    return html`
      <ui-button
        color="outlined"
        title="Adds a new part with a file value"
        aria-label="Adds a new part with a file value"
        @click="${this.handleMultipartAddFile}"
      >
        Add file part
      </ui-button>
      <ui-button
        color="outlined"
        title="Adds a new part with a text value"
        aria-label="Adds a new part with a text value"
        @click="${this.handleMultipartAddText}"
      >
        Add text part
      </ui-button>
    `
  }

  /**
   * @returns A template for currently rendered editor
   */
  protected renderEditor(): TemplateResult {
    switch (this.selected) {
      case 'raw':
        return this.renderRawEditor()
      case 'urlEncode':
        return this.renderUrlEncodeEditor()
      case 'multipart':
        return this.renderMultipartEditor()
      case 'file':
        return this.renderFileEditor()
      default:
        return this.renderNoEditor()
    }
  }

  /**
   * @returns The template for the raw editor
   */
  protected renderRawEditor(): TemplateResult {
    let { value } = this
    const model = this.readMetaModel('raw') as IRawBody[]
    const hasModel = Array.isArray(model) && model.length
    if (typeof value !== 'string') {
      value = ''
    }
    if (!value && hasModel) {
      const [item] = model
      value = item.value
    }
    return this.renderTextEditor(value as string)
  }

  /**
   * @param value The editor value
   * @returns The template for the Monaco editor
   */
  protected renderTextEditor(value: string): TemplateResult {
    const { contentType } = this
    return html` <http-body-text-editor
      .value="${value}"
      .contentType="${contentType}"
      @change="${this.handleRawChange}"
      allowFormInfo
    ></http-body-text-editor>`
  }

  /**
   * @returns A template for the application/x-www-form-urlencoded editor
   */
  protected renderUrlEncodeEditor(): TemplateResult {
    const { autoEncode } = this
    const info = this.readMetaModel('urlEncode') as IUrlEncodedBodyMeta[] | undefined
    const model = Array.isArray(info) && info[0] && info[0].model
    return html`
      <http-body-urlencoded-editor
        ?autoEncode="${autoEncode}"
        .model="${ifProperty(model)}"
        @change="${this.handleUrlEncodeChange}"
        allowFormInfo
      ></http-body-urlencoded-editor>
    `
  }

  /**
   * @returns A template for the multipart editor
   */
  protected renderMultipartEditor(): TemplateResult {
    const model = this.readMetaModel('multipart') as IMultipartBody[]
    return html`
      <http-body-multipart-editor
        .model="${ifProperty(model)}"
        @change="${this.handleMultipartChange}"
        allowFormInfo
      ></http-body-multipart-editor>
    `
  }

  /**
   * @returns A template for the file input editor
   */
  protected renderFileEditor(): TemplateResult {
    return html`
      <div class="file-picker">
        <ui-button color="text" @click="${this.handlePickFile}">Choose a file</ui-button>
        ${this.renderFileDetail()}
      </div>
      <input type="file" class="binary-hidden" @change="${this.handleFileChange}" />
      ${this.renderFormInfo()}
    `
  }

  protected renderFormInfo(): TemplateResult {
    return html`
      <p class="form-info body-small">Note that the content-type header will be added when the request is sent.</p>
    `
  }

  /**
   * @returns A template for the missing selection
   */
  protected renderNoEditor(): TemplateResult {
    return html`<div class="empty-editor">Invalid editor selected.</div>`
  }

  /**
   * @returns A template for the file details.
   */
  protected renderFileDetail(): TemplateResult | string {
    if (!this.hasFile) {
      this.restoreFileModel()
    }
    if (!this.hasFile) {
      return ''
    }
    const { value } = this
    const { size, name } = value as File
    return html`
      <div class="file-info label-medium">
        <span class="file-name">${name || 'unknown name'}</span>
        <span class="file-size">${size} bytes</span>
        <ui-icon-button
          aria-label="Delete file"
          title="Deletes the file"
          @click="${this.handleClearFile}"
          ?disabled="${this.readOnly}"
        >
          <ui-icon icon="deleteOutline" role="presentation"></ui-icon>
        </ui-icon-button>
      </div>
    `
  }

  protected renderInvalidMime(): TemplateResult | string {
    if (!this.invalidMimeValue) {
      return ''
    }
    const id = this.invalidMimeMessage
    return html`
      <div class="invalid-mime body-medium">
        <ui-icon icon="warning" class="warning-icon"></ui-icon>
        ${id === 1 ? this.renderFixableInvalidMime('multipart/form-data') : ''}
        ${id === 2 ? this.renderFixableInvalidMime('application/x-www-form-urlencoded') : ''}
      </div>
    `
  }

  /**
   * @returns A template for fixable mime type mismatch message.
   */
  protected renderFixableInvalidMime(suggested: string): TemplateResult {
    return html`
      <p class="message body-medium">
        The <code>content-type</code> header has different value than <b>${suggested}</b>.
      </p>
      <ui-button
        class="fix"
        title="Updates the content type header for the request"
        @click="${this.autoFixMime}"
        color="tonal"
        >Fix</ui-button
      >
    `
  }
}
