import { html, nothing, PropertyValues } from 'lit'
import { ifDefined } from 'lit/directives/if-defined.js'
import { customElement } from 'lit/decorators.js'
import { ref } from 'lit/directives/ref.js'
import { classMap } from 'lit/directives/class-map.js'
import { repeat } from 'lit/directives/repeat.js'

import type {
  IPktComboboxOption,
  TPktComboboxTagPlacement,
  TPktComboboxDisplayValue,
} from './combobox-types'
import { findOptionByValue, findOptionIndex } from 'shared-utils/combobox/option-utils'
import { getSingleValueForInput } from 'shared-utils/combobox/input-utils'
import { slotUtils, optionStateUtils } from './combobox-utils'
import { slotContent } from '@/directives/slot-content'
import { ComboboxHandlers } from './combobox-handlers'

import '../input-wrapper'
import '../icon'
import '../tag'
import '../listbox'

// Re-export types for backward compatibility
export type { IPktComboboxOption, TPktComboboxTagPlacement } from './combobox-types'

export interface IPktCombobox {
  allowUserInput?: boolean
  typeahead?: boolean
  disabled?: boolean
  displayValueAs?: string
  errorMessage?: string
  fullwidth?: boolean
  hasError?: boolean
  helptext?: string | null
  helptextDropdown?: string | null
  helptextDropdownButton?: string | null
  id?: string
  includeSearch?: boolean
  label?: string | null
  maxlength?: number | null
  minlength?: number | null
  multiple?: boolean
  name?: string
  optionalTag?: boolean
  optionalText?: string
  options?: IPktComboboxOption[]
  defaultOptions?: IPktComboboxOption[]
  placeholder?: string | null
  requiredTag?: boolean
  requiredText?: string
  searchPlaceholder?: string
  tagPlacement?: TPktComboboxTagPlacement | null
  tagText?: string | null
  value?: string | string[]
  isOpen?: boolean
}

declare global {
  interface HTMLElementTagNameMap {
    'pkt-combobox': PktCombobox & HTMLSelectElement
  }
}

export class PktCombobox extends ComboboxHandlers implements IPktCombobox {
  // Bound handler for body click — stored for cleanup in disconnectedCallback
  private handleBodyClick = (e: MouseEvent) => {
    if (this._isOptionsOpen && !this.contains(e.target as Node)) {
      this.closeAndProcessInput()
    }
  }

  // Lifecycle methods

  connectedCallback(): void {
    super.connectedCallback()

    document?.body.addEventListener('click', this.handleBodyClick)

    this._options = []

    // Deep clone defaultOptions into options, preserving userAdded options
    if (this.defaultOptions && this.defaultOptions.length) {
      const userAdded = this.options?.filter((opt) => opt.userAdded) || []
      this.options = [...userAdded, ...JSON.parse(JSON.stringify(this.defaultOptions))]
      this._options = Array.isArray(this.options) ? [...this.options] : []
    }

    // If options are provided via the options slot, we need to extract them
    if (this.optionsController?.nodes && this.optionsController.nodes.length) {
      const parsedOptions = slotUtils.parseOptionsFromSlot(this.optionsController.nodes)
      if (parsedOptions.length) {
        this.options = [...parsedOptions]
        this._options = [...parsedOptions]
        this._optionsFromSlot = true
        this._lastSlotGeneration = this.optionsController.generation
      }
    }
  }

  protected willUpdate(changedProperties: Map<PropertyKey, unknown>): void {
    // Re-parse slot options when the controller detects mutations.
    // The controller increments its generation counter on each mutation, but
    // doesn't set any reactive properties — so we detect the change here.
    if (this._optionsFromSlot && this.optionsController) {
      const currentGen = this.optionsController.generation
      if (currentGen !== this._lastSlotGeneration) {
        this._lastSlotGeneration = currentGen
        const parsedOptions = slotUtils.parseOptionsFromSlot(this.optionsController.nodes)
        const userAdded = this._options.filter((o) => o.userAdded)
        this.options = [...userAdded, ...parsedOptions]
      }
    }
    super.willUpdate(changedProperties)
  }

  disconnectedCallback(): void {
    super.disconnectedCallback()
    document?.body.removeEventListener('click', this.handleBodyClick)
  }

  firstUpdated(changedProperties: PropertyValues): void {
    // Apply defaultValue before the base class firstUpdated, which calls
    // valueChanged(defaultValue) — a no-op in combobox. Setting this.value
    // here lets updated() handle the sync via the normal value-change path.
    if (this.defaultValue !== null && !this.value) {
      this.value = this.defaultValue
    }
    super.firstUpdated(changedProperties)
  }

  updated(changedProperties: PropertyValues): void {
    if (changedProperties.has('isOpen')) {
      this._isOptionsOpen = this.isOpen
    }

    // Handle value and _value changes.
    // Three cases:
    // 1. value changed from our own syncValueAndDispatch (internal sync) — skip value handler,
    //    but still process concurrent _value changes
    // 2. value changed externally — sync _value from value, dispatch events
    // 3. Only _value changed — sync value from _value, dispatch events
    const valueChanged = changedProperties.has('value')
    const internalChanged = changedProperties.has('_value')
    const isInternalSync = valueChanged && this._internalValueSync

    if (isInternalSync) {
      this._internalValueSync = false
      if (internalChanged) {
        this.syncValueAndDispatch(changedProperties.get('_value') as string[])
      }
    } else if (valueChanged) {
      const oldInternal = [...this._value]
      const newInternal = this.parseValue()
      if (newInternal.join(',') !== this._value.join(',')) {
        this._value = newInternal
      }
      this.updateMaxReached()
      this.syncValueAndDispatch(oldInternal)
    } else if (internalChanged) {
      this.syncValueAndDispatch(changedProperties.get('_value') as string[])
    }

    // If defaultOptions changed, update options (preserving userAdded)
    if (changedProperties.has('defaultOptions') && this.defaultOptions.length) {
      const userAdded =
        (Array.isArray(this.options) ? this.options : []).filter((opt) => opt.userAdded) || []
      this.options = [...userAdded, ...JSON.parse(JSON.stringify(this.defaultOptions))]
      this._options = Array.isArray(this.options) ? [...this.options] : []
    }

    if (changedProperties.has('options')) {
      const prevOptions =
        (changedProperties.get('options') as IPktComboboxOption[]) || this._options || []
      const mergedOptions = optionStateUtils.mergeWithUserAdded(this.options, prevOptions)
      this._options = mergedOptions
      if (mergedOptions.length > this.options.length) {
        this.options = mergedOptions
      }

      const syncResult = optionStateUtils.syncOptionsWithValues(this._options, this._value)
      this._options = syncResult.options

      if (syncResult.newValues.length > this._value.length) {
        const oldValue = [...this._value]
        this._value = syncResult.newValues
        this.syncValueAndDispatch(oldValue)
      }
    }
    if (changedProperties.has('_search')) {
      this.dispatchEvent(
        new CustomEvent('search', {
          detail: this._search,
          bubbles: false,
        }),
      )
    }
    // Sync text input display value for single+typeahead when dropdown is closed
    if (
      !this._isOptionsOpen &&
      !this.multiple &&
      this._hasTextInput &&
      this.inputRef.value &&
      this.inputRef.value.type !== 'hidden'
    ) {
      const displayValue = this._value[0]
        ? getSingleValueForInput(this._value[0], this.options, this.displayValueAs)
        : ''
      if (this.inputRef.value.value !== displayValue) {
        this.inputRef.value.value = displayValue
      }
    }

    super.updated(changedProperties)
  }

  /**
   * Override form reset to properly restore combobox state.
   * The base class deselects all options and sets value/defaultValue, but
   * combobox needs to re-sync _options with the restored values and clean up
   * user-added options and UI state.
   */
  protected override formResetCallback(): void {
    this.touched = false

    // Restore value from defaultValue (set by base class firstUpdated from
    // the initial value attribute, per MDN HTMLInputElement.defaultValue)
    const resetValue = this.defaultValue || (this.multiple ? '' : '')
    this.value = resetValue
    this._value = this.parseValue()

    // Remove user-added options, then re-sync selection state with restored _value.
    // We must create new arrays because the base class mutates option objects in place.
    this._options = this._options
      .filter((o) => !o.userAdded)
      .map((o) => ({ ...o, selected: this._value.includes(o.value) }))
    this.options = this.options
      .filter((o) => !o.userAdded)
      .map((o) => ({ ...o, selected: this._value.includes(o.value) }))

    // Reset UI state
    this._search = ''
    this._isOptionsOpen = false
    this._userInfoMessage = ''
    this._addValueText = null
    this._inputFocus = false
    this.updateMaxReached()

    if (this.inputRef.value && this.inputRef.value.type !== 'hidden') {
      this.inputRef.value.value = ''
    }

    this.internals.setFormValue('')
    this.internals.ariaInvalid = 'false'
    this.requestUpdate()
  }

  attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
    // Don't set _value here for 'value' changes — this.value hasn't been updated yet
    // (super.attributeChangedCallback does that). Let updated() handle the sync.
    if (name === 'options') {
      this._options = Array.isArray(this.options) ? [...this.options] : []
      const syncResult = optionStateUtils.syncOptionsWithValues(this._options, this._value)
      this._options = syncResult.options
      if (syncResult.newValues.length > this._value.length) {
        this._value = syncResult.newValues
      }
      this._search = ''
    }
    super.attributeChangedCallback(name, _old, value)
  }

  // Render methods

  render() {
    return html`
      <pkt-input-wrapper
        .label=${this.label}
        .helptext=${this.helptext}
        .helptextDropdown=${ifDefined(this.helptextDropdown)}
        .helptextDropdownButton=${ifDefined(this.helptextDropdownButton)}
        ?fullwidth=${this.fullwidth}
        ?hasError=${this.hasError}
        ?inline=${this.inline}
        ?disabled=${this.disabled}
        .errorMessage=${this.errorMessage}
        ?optionalTag=${this.optionalTag}
        .optionalText=${this.optionalText}
        ?requiredTag=${this.requiredTag}
        .requiredText=${this.requiredText}
        .tagText=${this.tagText}
        useWrapper=${this.useWrapper}
        .forId=${this._hasTextInput ? this.id + '-input' : this.id + '-combobox'}
        ?hasFieldset=${!this._hasTextInput}
        class="pkt-combobox__wrapper"
        @labelClick=${this.handleInputClick}
      >
        <div class="pkt-contents" slot="helptext">${slotContent(this, 'helptext')}</div>
        <div class="pkt-combobox" @focusout=${this.handleFocusOut}>
          <div
            class=${classMap({
              'pkt-combobox__input': true,
              'pkt-combobox__input--fullwidth': this.fullwidth,
              'pkt-combobox__input--open': this._isOptionsOpen,
              'pkt-combobox__input--error': this.hasError,
              'pkt-combobox__input--disabled': this.disabled,
            })}
            id=${ifDefined(!this._hasTextInput ? `${this.id}-combobox` : undefined)}
            role=${ifDefined(!this._hasTextInput ? 'combobox' : undefined)}
            aria-expanded=${ifDefined(
              !this._hasTextInput ? (this._isOptionsOpen ? 'true' : 'false') : undefined,
            )}
            aria-controls=${ifDefined(!this._hasTextInput ? `${this.id}-listbox` : undefined)}
            aria-haspopup=${ifDefined(!this._hasTextInput ? 'listbox' : undefined)}
            aria-labelledby=${ifDefined(
              !this._hasTextInput ? `${this.id}-combobox-label` : undefined,
            )}
            aria-activedescendant=${ifDefined(
              !this._hasTextInput &&
                this._value[0] &&
                !!findOptionByValue(this.options, this._value[0])
                ? `${this.id}-listbox-${findOptionIndex(this._options, this._value[0])}`
                : undefined,
            )}
            aria-description=${ifDefined(this._selectionDescription || undefined)}
            tabindex=${!this._hasTextInput ? (this.disabled ? '-1' : '0') : '-1'}
            @click=${this.handleInputClick}
            @keydown=${!this._hasTextInput ? this.handleSelectOnlyKeydown : nothing}
            ${ref(this.triggerRef)}
          >
            ${!this._hasTextInput &&
            this.placeholder &&
            (!this._value.length || (this.multiple && this.tagPlacement == 'outside')) &&
            !this._inputFocus
              ? html`<span class="pkt-combobox__placeholder" @click=${this.handlePlaceholderClick}
                  >${this.placeholder}</span
                >`
              : this.tagPlacement !== 'outside'
                ? this.renderSingleOrMultipleValues()
                : nothing}
            ${this.renderInputField()}
            <pkt-icon
              class=${classMap({
                'pkt-combobox__arrow-icon': true,
                'pkt-combobox__arrow-icon--open': this._isOptionsOpen,
              })}
              name="chevron-thin-down"
              aria-hidden="true"
            ></pkt-icon>
          </div>

          <pkt-listbox
            id="${this.id}-listbox"
            .options=${this._options}
            .isOpen=${this._isOptionsOpen}
            .searchPlaceholder=${this.searchPlaceholder}
            .label="Liste: ${this.label || ''}"
            ?include-search=${this.includeSearch}
            ?is-multi-select=${this.multiple}
            ?allow-user-input=${this.allowUserInput && !this._maxIsReached}
            ?max-is-reached=${this._maxIsReached}
            .customUserInput=${ifDefined(this._addValueText)}
            .userMessage=${this._userInfoMessage}
            @search=${this.handleSearch}
            @option-toggle=${this.handleOptionToggled}
            @select-all=${this.addAllOptions}
            @close-options=${() => (this._isOptionsOpen = false)}
            @tab-close=${() => this.closeAndProcessInput()}
            .searchValue=${this._search || null}
            .maxLength=${this.maxlength || 0}
            ${ref(this.listboxRef)}
          ></pkt-listbox>
        </div>

        ${this.tagPlacement === 'outside' && this.multiple
          ? html`<div class="pkt-combobox__tags-outside">
              ${this.renderSingleOrMultipleValues()}
            </div>`
          : nothing}
      </pkt-input-wrapper>
    `
  }

  private renderInputField() {
    return this.typeahead || this.allowUserInput
      ? html`
          <div class="pkt-combobox__input-div combobox__input">
            <input
              type="text"
              id="${this.id}-input"
              name=${(this.name || this.id) + '-input'}
              placeholder=${ifDefined(
                !this._value.length || (this.multiple && this.tagPlacement === 'outside')
                  ? this.placeholder
                  : undefined,
              )}
              @input=${this.handleInput}
              @change=${(e: Event) => {
                e.stopPropagation()
                e.stopImmediatePropagation()
              }}
              @keydown=${this.handleInputKeydown}
              @focus=${this.handleFocus}
              @blur=${this.handleBlur}
              autocomplete="off"
              role="combobox"
              aria-expanded=${this._isOptionsOpen ? 'true' : 'false'}
              aria-label=${ifDefined(this.label)}
              aria-autocomplete=${this.typeahead ? 'both' : this.allowUserInput ? 'list' : 'none'}
              aria-controls="${this.id}-listbox"
              aria-activedescendant=${ifDefined(
                this._value[0] && !!findOptionByValue(this.options, this._value[0])
                  ? `${this.id}-listbox-${findOptionIndex(this._options, this._value[0])}`
                  : undefined,
              )}
              aria-description=${ifDefined(this._selectionDescription || undefined)}
              ${ref(this.inputRef)}
            />
          </div>
        `
      : html`
          <input
            type="hidden"
            id="${this.id}-input"
            name=${(this.name || this.id) + '-input'}
            .value=${this._value.join(',')}
            ${ref(this.inputRef)}
          />
        `
  }

  private renderSingleOrMultipleValues() {
    // Single select with text input: value is shown in the input field, not as a span
    if (!this.multiple && this._hasTextInput) return nothing

    const isSingleValueDisplay = !this.multiple

    // Single value displayed as text (select-only mode)
    const singleValueContent = this.renderValueTag(findOptionByValue(this.options, this._value[0]))

    // Multiple values displayed as tags, wrapped in a list for accessibility
    const isOutside = this.tagPlacement === 'outside'
    const multipleValuesContent = html`
      <ul role="list" class="pkt-combobox__tag-list">
        ${repeat(
          this._value,
          (value: string) => value,
          (value: string, index: number) => {
            const option = findOptionByValue(this.options, value)
            const tagSkinColor = option?.tagSkinColor
            return html`
              <li
                role="listitem"
                @click=${isOutside ? nothing : (e: MouseEvent) => e.stopPropagation()}
                @mousedown=${isOutside ? nothing : (e: MouseEvent) => e.preventDefault()}
              >
                <pkt-tag
                  skin=${tagSkinColor || 'blue-dark'}
                  ?closeTag=${!this.disabled}
                  .buttonTabindex=${isOutside ? undefined : -1}
                  @close=${() => this.handleTagRemove(value)}
                  @keydown=${isOutside
                    ? nothing
                    : (e: KeyboardEvent) => this.handleTagKeydown(e, index)}
                >
                  ${this.renderValueTag(option)}
                </pkt-tag>
              </li>
            `
          },
        )}
      </ul>
    `

    return isSingleValueDisplay ? singleValueContent : multipleValuesContent
  }

  private renderValueTag(option: IPktComboboxOption | null) {
    if (!option) return ''
    const displayAs = this.displayValueAs as TPktComboboxDisplayValue
    switch (displayAs) {
      case 'prefixAndValue':
        return html`<span class="pkt-combobox__value" data-focusfix=${this.id}
          >${option.prefix || ''} ${option.value}</span
        >`
      case 'value':
        return html`<span class="pkt-combobox__value" data-focusfix=${this.id}
          >${option.value}</span
        >`
      case 'label':
      default:
        return html`<span class="pkt-combobox__value" data-focusfix=${this.id}
          >${option.label || option.value}</span
        >`
    }
  }
}

export default PktCombobox

try {
  customElement('pkt-combobox')(PktCombobox)
} catch (e) {
  console.warn('Forsøker å definere <pkt-combobox>, men den er allerede definert')
}
