const { h, matter, preactHooks } = globalThis.ami
const { useEffect, useMemo, useRef, useState } = preactHooks
const { Button, Input, Link, Box } = matter

const I18N_DEFAULT = {
  loading: 'Loading...',
  loadingError: 'Failed to load data.',
  resetFilters: 'Reset filters',
  loadMore: 'Load more',
  ascending: 'ascending',
  descending: 'descending',
  noResults: 'No results found.'
}
const MAX_FILTERS_SINGLE_ROW = 3
const LOAD_MORE_LIMIT = 24

// Special filter keys.
const FILTER_KEY_TEXT = 'text'
const FILTER_KEY_SELECT = 'select'
const FILTER_KEY_ORDER = 'order'

const TEXT_CENTER = 'text-center'

// -----------------------------------------------------------------------------
// Subcomponents
// -----------------------------------------------------------------------------

function FilterBar ({ i18n, filters, resetLoadMore }) {
  const layout = useMemo(() => filters && getFilterLayout(filters), [filters?.length])

  if (!filters?.length) {
    return null
  }

  return h(Box, { className: 'text-txa bg-bga rounded-2xl' },
    h('div', { className: `grid gap-5 grid-cols-${layout.colNumber} md:grid-cols-2 sm:grid-cols-1` }, filters?.map?.(filter => {
      // Determine if text filter should span all columns.
      const colspan = ((filter.type === FILTER_KEY_TEXT) && layout.fullWidthText) && layout.colNumber
      const onChange = e => {
        const filterValueMap = filters
          .map(f => ({ [f.name]: f.value }))
          .reduce((a, b) => ({ ...a, ...b }), {})
        setHash({ ...filterValueMap, [filter.name]: e.target.value })
        filter.type !== FILTER_KEY_ORDER && resetLoadMore()
      }

      return h(FilterInput, { ...filter, colspan, onChange })
    })),
    // Reset filters.
    i18n.resetFilters && h(Link, {
      _variant: 'underline',
      _model: { name: i18n.resetFilters, value: '#' },
      onClick: e => {
        e.preventDefault()
        if (!globalThis.location.hash) {
          return
        }
        resetHash()
        resetLoadMore()
      }
    })
  )
}

function FilterInput ({ colspan, label, type, name, values, value, defaultValue, onChange }) {
  const labelClass = colspan ? `lg:col-span-${colspan}` : ''
  return h('label', { className: `block ${labelClass}` },
    h('span', { className: 'font-bold' }, label),
    type === FILTER_KEY_TEXT
      ? h(Input, {
        _size: 'sm',
        name,
        type: 'search',
        value,
        onInput: onChange
      })
      : h(Input, {
        _size: 'sm',
        name,
        type: 'select',
        onChange,
        children: values.map(({ name: optionName, value: optionValue }) =>
          h('option', {
            value: optionValue,
            selected: (value === optionValue) || (!value && optionValue === defaultValue)
          }, optionName)
        )
      })
  )
}

// -----------------------------------------------------------------------------
// Helper functions
// -----------------------------------------------------------------------------

// Determine filter layout according to its count/type.
function getFilterLayout (filters) {
  const filterNumber = filters.length
  // Indicates the first field is text and is the only one of this type.
  const isFirstFilterText = filters.every((filter, i) => {
    const isText = filter?.type === FILTER_KEY_TEXT
    return i === 0 ? isText : !isText
  })

  if (filterNumber <= MAX_FILTERS_SINGLE_ROW) {
    return {
      // For a single filter, it spans all columns only if it is of type text.
      colNumber: filterNumber + Number((filterNumber === 1) && !isFirstFilterText),
      fullWidthText: false
    }
  }

  const layouts = [
    { colNumber: 3, fullWidthText: false },
    { colNumber: 2, fullWidthText: false },
    isFirstFilterText && { colNumber: 3, fullWidthText: true },
    isFirstFilterText && { colNumber: 2, fullWidthText: true }
  ].filter(Boolean)
  let last

  for (const currentLayout of layouts) {
    const currentFilterNumber = filterNumber - Number(currentLayout.fullWidthText)
    const orphanNumber = currentFilterNumber % currentLayout.colNumber
    if (!last || (orphanNumber < last.orphanNumber)) {
      last = { orphanNumber, layout: currentLayout }
      if (orphanNumber === 0) {
        break
      }
    }
  }

  return last.layout
}

// Applies properties according to filter type.
function decorateFilterItem (filterItem, allItems, i18n) {
  const filterHandlers = {
    [FILTER_KEY_TEXT]: {
      onFilter: (filterItems, filterValue) =>
        filterValue
          ? filterItems.filter(item => filterItem.keys.some(key =>
             `${item[key]}`.toLowerCase().includes(filterValue.toLowerCase())
          ))
          : filterItems
    },
    [FILTER_KEY_SELECT]: {
      name: filterItem.keys.at(-1)
        ?.toLowerCase().replaceAll(/[^A-Za-z0-9]/g, '') || FILTER_KEY_SELECT,
      getValues: () => [
        allItems[0] && (allItems[0][filterItem.keys[0]] === undefined) && { name: filterItem.keys[0], value: '' },
        ...filterItem.keys.flatMap(key =>
          [...new Set(allItems.flatMap(item => (item[key] || '')).filter(Boolean))]
        ).sort((v1, v2) => v1.localeCompare(v2)).map(value => ({ name: value, value }))
      ].filter(Boolean),

      onFilter: (items, filterValue) =>
        filterValue
          ? items.filter(item => filterItem.keys.some(key =>
            // Check if the value matches (single) or one of them (array).
            (item[key] === filterValue) || (Array.isArray(item[key]) && item[key].includes(filterValue)))
          )
          : items
    },
    [FILTER_KEY_ORDER]: {
      getValues: () => filterItem.keys.map(key => {
        const isDesc = key.startsWith('-')
        const rawKey = isDesc ? key.substring(1) : key

        // Determine if order direction (asc/desc) is needed
        const hasOpposite = filterItem.keys.includes(`${isDesc ? '' : '-'}${rawKey}`)
        let directionSuffix = ''
        if (hasOpposite) {
          directionSuffix = ` (${isDesc ? i18n.descending : i18n.ascending})`
        }

        return {
          name: `${rawKey}${directionSuffix}`,
          value: key
        }
      }),

      onFilter: (items, filterValue, sign) => items.sort((a, b) =>
        (typeof a[filterValue] === 'number'
          ? (a[filterValue] - b[filterValue])
          : `${a[filterValue]}`.localeCompare?.(`${b[filterValue]}`)) * sign
      )
    }
  }

  const name = filterHandlers[filterItem.type]?.name || filterItem.type
  const values = filterHandlers[filterItem.type]?.getValues?.() || []
  const onFilter = filterHandlers[filterItem.type]?.onFilter

  return {
    ...filterItem,
    label: filterItem.label || (allItems[0] && filterItem.keys.find(key => allItems[0][key] !== undefined)),
    name,
    values,
    value: '',
    defaultValue: values[0]?.value,
    onFilter
  }
}

// Filter items according to current filter values and load more cursor position.
function applyFiltersLoadMore ({ filters, items, i18n, loadMoreCursor }) {
  // Apply filters.
  let filteredItems = [...items]
  for (const { type, onFilter, value, defaultValue } of filters) {
    let filterValue = value || defaultValue || ''
    if (type === FILTER_KEY_ORDER) {
      const sign = filterValue.startsWith?.('-') ? -1 : 1
      filterValue = filterValue.replace(/^-/, '')
      filteredItems = onFilter(filteredItems, filterValue, sign)
    } else if (onFilter) {
      filteredItems = onFilter(filteredItems, filterValue)
    }
  }

  // Apply load more.
  let hasLoadMore = false
  if (i18n.loadMore) {
    const filteredItemsLength = filteredItems.length
    filteredItems = filteredItems.slice(0, loadMoreCursor + LOAD_MORE_LIMIT)
    hasLoadMore = (filteredItems.length < filteredItemsLength)
  }

  return [filteredItems, hasLoadMore]
}

function setHash (params) {
  const search = new URLSearchParams()
  for (const [key, value] of Object.entries(params)) { value && search.set(key, value) }
  if (search.size > 0) {
    globalThis.history.replaceState({}, '', `#${search.toString()}`)
    globalThis.dispatchEvent(new globalThis.HashChangeEvent('hashchange'))
  } else {
    resetHash()
  }
}

function resetHash () {
  // Avoid scroll to top.
  globalThis.history.replaceState({}, '', globalThis.location.href.split('#')[0])
  globalThis.dispatchEvent(new globalThis.HashChangeEvent('hashchange'))
}

function getFiltersFromHash (filters) {
  const search = new URLSearchParams(globalThis.location.hash.substring(1))
  return filters?.map?.(filterItem => ({ ...filterItem, value: search.get(filterItem.name) || '' }))
}

// -----------------------------------------------------------------------------
// Main component
// -----------------------------------------------------------------------------

export default function FilteredData ({ getData, filters: initialFilters, Items, i18n, ...props }) {
  const [data, setData] = useState({
    errorMessage: null,
    i18n: { ...I18N_DEFAULT, ...i18n },
    items: [],
    filters: null,
    loadMoreCursor: 0
  })
  const itemsRef = useRef(null)

  // Resolve data.
  useEffect(() => {
    (async () => {
      let items
      try {
        items = await getData?.(props) || []
      } catch (error) {
        setData(currentData => ({ ...currentData, errorMessage: data.i18n.loadingError }))
        console.error(error)
        return
      }
      const filters = getFiltersFromHash( // Set initial filter values.
        initialFilters?.map?.(filter => decorateFilterItem(filter, items, data.i18n)) // Apply additional properties.
      ) || []

      setData({ ...data, items, filters })

      // Listen to hash changes. Multiple functions to satisfy SonarQube.
      const setDataHandler = currentData => ({
        ...currentData,
        filters: getFiltersFromHash(currentData.filters)
      })
      const hashchangeHandler = () => setData(setDataHandler)
      globalThis.addEventListener('hashchange', hashchangeHandler)
    })()
  }, [])

  // Focus on the first newly loaded element after load more click.
  useEffect(() => {
    if (data.loadMoreCursor > 0) {
      // Determine item container.
      const container = [...itemsRef?.current?.querySelectorAll('[tabindex="0"]') || []]?.find(element => element.parentNode.children.length >= data.loadMoreCursor)?.parentNode
      container?.children?.[data.loadMoreCursor]?.focus?.()
    }
  }, [data.loadMoreCursor])

  // Error.
  if (data.errorMessage) {
    return h('div', { className: TEXT_CENTER }, data.errorMessage)
  }

  // Not ready yet or misconfigured.
  if (!data.filters) {
    return Boolean(data.i18n.loading) && h(Box, { className: TEXT_CENTER }, data.i18n.loading)
  }

  // Apply filters & load more.
  const [filteredItems, hasLoadMore] = applyFiltersLoadMore(data)
  // Reset load more cursor.
  const resetLoadMore = () => data.i18n.loadMore && setData(currentData => ({ ...currentData, loadMoreCursor: 0 }))

  return h('div', { className: 'space-y-5' },
    // Filters.
    h(FilterBar, { ...data, resetLoadMore }),
    // Item list.
    Items && ((filteredItems.length > 0) || !data.i18n.noResults) && h('div', { ref: itemsRef },
      h(Items, { items: filteredItems })
    ),
    data.i18n.noResults && !filteredItems.length && h('div', { className: TEXT_CENTER }, data.i18n.noResults),
    // Load more.
    hasLoadMore && h('div', { className: 'flex justify-center' },
      h(Button, {
        className: 'w-auto',
        onClick: () => { setData({ ...data, loadMoreCursor: data.loadMoreCursor + LOAD_MORE_LIMIT }) },
        _model: {
          name: data.i18n.loadMore
        }
      })
    )
  )
}
