import Excel from 'exceljs'

import ICON_ARROW_UP from './arrow-up.svg'
import ICON_ARROW_UP_DOWN from './arrow-up-down.svg'

const { h, register, matter, preactHooks, t } = globalThis.ami
const { Box, Button, Heading, Icon, Input, Link, Message, Table } = matter // Amidala components
const { useState, useEffect, useMemo, useRef } = preactHooks // Preact hooks

const MAX_SIZE = 100_000
const MAX_ROWS = 200
const MAX_COLS = 15
const BYTE_LENGTH = 1000
const MAX_FILTER_OPTION_LENGTH = 20
const QUERY_ITEMS_PER_PAGE = 50
const DEBOUNCE_DELAY = 300

// Fetch & parse the document.
async function fetchAndParseSpreadsheet (src, isQueryable) {
  if (!src) {
    throw new Error('Missing document source.')
  }

  // Fetch the document.
  const res = await fetch(src)
  if (!res.ok) {
    throw new Error(`Error loading document: ${src}`)
  }

  const arrayBuffer = await res.arrayBuffer()
  if (arrayBuffer.byteLength > MAX_SIZE) {
    throw new Error(`Please reduce the file size to a maximum of ${MAX_SIZE / BYTE_LENGTH}KB.`)
  }

  const workbook = new Excel.Workbook()
  await workbook.xlsx.load(arrayBuffer)
  if (workbook.worksheets.length < 1) {
    throw new Error('No sheets found in the document.')
  }

  const title = workbook.getWorksheet().name
  const sheet = workbook.getWorksheet(title)
  const workbookTitle = workbook?.title
  const workbookDescription = workbook?.description
  if ((!isQueryable && (sheet.actualRowCount > MAX_ROWS)) || sheet.actualColumnCount > MAX_COLS) {
    throw new Error(`Please reduce the amount of content to display in this table to a maximum of ${MAX_COLS} columns and ${MAX_ROWS} rows.`)
  }

  const { header, table } = getSheetData(sheet)
  return { ready: true, header, table, filteredTable: table, sortedTable: table, isQueryable, title, workbookTitle, workbookDescription }
}

function getSheetData (sheet) {
  let header
  const table = []
  sheet.eachRow((row, key) => {
    const rowData = []
    rowData.tableRowKey = key
    const isHeader = row.number === 1
    row.eachCell({ includeEmpty: true }, cell => {
      rowData.push({
        text: getCellValue(cell) ?? cell.value,
        link: cell.hyperlink,
        style: getStyle(cell)
      })
    })
    if (isHeader) {
      header = rowData
    } else {
      // Fix includeEmpty issue
      while (rowData.length < header.length) {
        rowData.push({ text: '' })
      }
      table.push(rowData)
    }
  })
  return { header, table }
}

function getCellValue (cell) {
  const dateValue = cell.value?.toLocaleDateString?.(document.documentElement.lang, { timeZone: 'UTC' })
  const superScriptSubscriptValue = cell.value?.richText?.map?.(text => text.text).join('')
  const formulaValue = { 'TRUE()': 'True', 'FALSE()': 'False' }[cell.value?.formula] ?? cell.value?.result ?? cell.value?.text
  const booleanValue = typeof cell.value === 'boolean' ? String(cell.value) : cell.value

  return dateValue ?? formulaValue ?? superScriptSubscriptValue ?? booleanValue
}

function getStyle (cell) {
  const styles = []
  cell.style.alignment?.horizontal && styles.push(`text-align: ${cell.style.alignment.horizontal}`)
  cell.style.font?.bold && styles.push('font-weight: bold')
  cell.style.font?.italic && styles.push('font-style: italic')
  return styles.join(';')
}

function useFeatures (rawFeatures) {
  // Available features: sort,search,filter=column_number
  return useMemo(() => {
    // Keys treated as sets (mutliple values)
    const FEATURE_SET_KEYS = new Set(['filter'])
    const featureSplit = rawFeatures?.split?.(',') || []
    return featureSplit.reduce((acc, curr) => {
      const [key, value] = curr.split('=').map(v => v.trim())
      if (FEATURE_SET_KEYS.has(key)) {
        acc[key] = [...acc[key] || [], Number.parseInt(value, 10) || value]
      } else {
        acc[key] = true
      }
      return acc
    }, {})
  }, [rawFeatures])
}

function Filter ({ data, features, onQuery, id }) {
  const [filterFields, setFilterFields] = useState([])
  const [filterVals, setFilterVals] = useState(new Map())
  const [searchText, setSearchText] = useState('')
  const [isQueried, setIsQueried] = useState(false)

  useEffect(() => {
    if (data?.table?.length && features.filter?.length) {
      data.header && setFilterFields(features.filter.map(rawColumnNumber => {
        const columnNumber = rawColumnNumber - 1
        const headerCell = data.header[columnNumber]
        if (!headerCell) {
          return null
        }

        return {
          columnNumber,
          name: headerCell.text,
          // Filter value aggregation is case-sensitive
          values: [
            ...data.table.reduce((acc, row) => {
              const value = row[columnNumber]?.text
              if (typeof value === 'number' || value) {
                acc.add(value)
              }
              return acc
            }, new Set())
          ].sort((v1, v2) => v1?.toLowerCase?.() < v2?.toLowerCase?.() ? -1 : 1).map(String)
        }
      }).filter(Boolean).sort())
    }
  }, [data])

  useEffect(() => () => clearTimeout(searchTimeoutRef.current), [])

  const handleQueryUpdate = (text, filters) => {
    onQuery?.(text, filters)
    setIsQueried(!!text || filters.size > 0)
  }

  const handleFilterChange = (value, field) => {
    const newFilterVals = new Map(filterVals)
    if (value) {
      newFilterVals.set(field.columnNumber, value)
    } else {
      newFilterVals.delete(field.columnNumber)
    }
    setFilterVals(newFilterVals)
    handleQueryUpdate(searchText, newFilterVals)
  }

  const searchTimeoutRef = useRef(null)
  const textProps = {
    _size: 'sm',
    type: 'search',
    placeholder: t('Enter your search term'),
    name: 'text',
    value: searchText,
    'aria-controls': id,
    onInput: e => {
      const { value } = e.target
      setSearchText(value)
      clearTimeout(searchTimeoutRef.current)
      // Adding a debounce to avoid too many queries.
      searchTimeoutRef.current = setTimeout(() => {
        handleQueryUpdate(value, filterVals)
      }, DEBOUNCE_DELAY)
    }
  }

  return (features.search || (filterFields.length > 0)) && h('div', { className: 'space-y-5' },
    h('div', { className: 'flex md:flex-col sm:flex-col gap-5' },
      // Search box.
      h('div', { className: 'grow' },
        h(Input, textProps)
      ),
      // Filters.
      filterFields.map((field, fieldIndex) => h('div',
        h(Input, {
          type: 'select',
          name: `filter-${fieldIndex + 1}`,
          value: filterVals.get(field.columnNumber) || '',
          'aria-controls': id,
          onChange: e => {
            const { value } = e.target
            handleFilterChange(value, field)
          },
          children: [
            h('option', { selected: true, value: '' }, field.name),
            ...field.values.map(value =>
              h('option', { value }, value.length > MAX_FILTER_OPTION_LENGTH
                ? `${value.substring(0, MAX_FILTER_OPTION_LENGTH)}...`
                : value
              )
            )
          ]
        })
      ))
    ),
    // Reset filters
    isQueried && h(Link, {
      role: 'button',
      _variant: 'full',
      'aria-controls': id,
      _model: {
        name: t('Reset filters'),
        value: '#'
      },
      onClick: e => {
        e.preventDefault()
        setSearchText('')
        setFilterVals(new Map())
        onQuery?.('', new Map())
        setIsQueried(false)
      }
    })
  )
}

function TableRow ({ key, row, sortData, isHeader, onColumnSort }) {
  return h('tr', { key }, row.map((cell, cellIndex) => {
    // Regular cell.
    if (!isHeader) {
      return h('td', { className: 'whitespace-pre-wrap', style: cell.style }, cell.link
        ? h(Link, { _model: { name: cell.text, value: cell.link }, _variant: 'full' })
        : h('span', {}, cell.text)
      )
    }

    // Non sortable header.
    if (!sortData) {
      return h('th', { className: 'whitespace-pre-wrap', style: cell.style }, cell.text)
    }

    const sortProps = { icon: ICON_ARROW_UP_DOWN, flipClass: '', label: () => t('Click to sort') }
    if (sortData?.columnNumber === cellIndex) {
      sortProps.icon = ICON_ARROW_UP
      sortProps.label = name => t('Sorted ascending - {0}', name)
      if (!sortData.isAsc) {
        sortProps.flipClass = 'rotate-180'
        sortProps.label = name => t('Sorted descending - {0}', name)
      }
    }

    // Sortable header.
    return h('th', {
      className: 'cursor-pointer whitespace-nowrap clickable',
      'aria-live': 'assertive',
      'aria-label': sortProps.label(cell.text),
      style: cell.style,
      onClick: () => onColumnSort(cellIndex),
      children: h('div', { className: 'flex gap-1' },
        cell.text,
        h('div', { className: `flex items-center justify-center ${sortProps.flipClass}` },
          h(Icon, { _size: 'sm' }, sortProps.icon)
        )
      )
    })
  }))
}

function TableContents ({ features, data, sortData, page, setPage, onColumnSort, id, 'aria-live': ariaLive }) {
  let dataRows = data.sortedTable
  let pageCount
  if (page !== null) {
    dataRows = data.sortedTable.slice(0, page * QUERY_ITEMS_PER_PAGE)
    pageCount = Math.ceil(data.sortedTable.length / QUERY_ITEMS_PER_PAGE)
  }

  const rows = [data.header, ...dataRows]
  const noItems = rows.length <= 1

  return [
    noItems && h('div', { className: 'text-center' }, 'No items.'),
    !noItems && h(Table, { id, 'aria-live': ariaLive }, rows.map((row, rowIndex) =>
      h(TableRow, { key: row.tableRowKey, row, sortData: features.sort && sortData, isHeader: rowIndex === 0, onColumnSort }))
    ),
    (page > 0) && (page < pageCount) && h('div', { className: 'text-center' },
      h(Button, {
        _size: 'sm',
        'aria-controls': id,
        _model: {
          name: t('Load more')
        },
        onClick: e => {
          e.preventDefault()
          setPage(page + 1)
        }
      })
    )
  ]
}

function getQueryKey (text) {
  return typeof text === 'number' ? `${text}` : text
}

function getOnQuery (data, setData, sortTable, setPage) {
  return (rawSearchText, filterVals) => {
    let newTable = data.table
    const searchText = rawSearchText?.trim?.()?.toLowerCase()

    if (searchText) {
      newTable = newTable.filter(row => row.some(cell => getQueryKey(cell.text)?.toLowerCase?.().includes?.(searchText)))
    }

    for (const [columnNumber, value] of filterVals.entries()) {
      newTable = newTable.filter(row => getQueryKey(row[columnNumber]?.text) === value)
    }

    setData({
      ...data,
      filteredTable: newTable,
      sortedTable: sortTable(newTable)
    })
    setPage(1)
  }
}

function getCompareKey (rawValue) {
  return typeof rawValue === 'number' ? rawValue : (rawValue?.toLowerCase?.() || '')
}

function sortCompare (rawValue1, rawValue2, multiplier) {
  const value1 = getCompareKey(rawValue1)
  const value2 = getCompareKey(rawValue2)
  // Numbers go before strings
  if (typeof value1 === 'number' && typeof value2 !== 'number') {
    return -1 * multiplier
  } else if (typeof value1 !== 'number' && typeof value2 === 'number') {
    return multiplier
  }

  // Both of same type or falsy
  if (value1 < value2) {
    return -multiplier
  } else if (value2 < value1) {
    return multiplier
  } else {
    return 0
  }
}

function getSortTable (sortData) {
  return table => {
    const col = sortData.columnNumber

    return col === null
      ? table
      : [...table].sort((row1, row2) => sortCompare(row1[col]?.text, row2[col]?.text, sortData.isAsc ? 1 : -1))
  }
}

function getOnColumnSort (sortData, setSortData) {
  return cellIndex => {
    if (sortData.columnNumber === cellIndex) {
      if (sortData.isAsc) {
        setSortData({ columnNumber: cellIndex, isAsc: false })
      } else {
        setSortData({ columnNumber: null, isAsc: true })
      }
    } else {
      setSortData({
        columnNumber: cellIndex,
        isAsc: true
      })
    }
  }
}

export function StandaloneTable ({ src, features: rawFeatures, prefix, 'show-title-description': showTitleDescription }) {
  const [data, setData] = useState({ ready: false, table: [], filteredTable: [], sortedTable: [], isQueryable: false, title: null, error: null })
  const [page, setPage] = useState(null)
  const [sortData, setSortData] = useState({ columnNumber: null, isAsc: true })
  const features = useFeatures(rawFeatures)
  const [id] = useState(() => `_${crypto.randomUUID()}`)

  const sortTable = getSortTable(sortData)
  const onQuery = getOnQuery(data, setData, sortTable, setPage)
  const onColumnSort = getOnColumnSort(sortData, setSortData)

  useEffect(() => {
    (async function () {
      try {
        const isQueryable = features.search || features.filter
        setData(await fetchAndParseSpreadsheet(src, isQueryable))
        if (isQueryable) {
          setPage(1)
        }
      } catch (error) {
        setData({ error: error.message })
      }
    })()
  }, [src])

  useEffect(() => {
    setData({
      ...data,
      sortedTable: sortData.columnNumber === null ? data.filteredTable : sortTable(data.filteredTable)
    })
  }, [sortData.columnNumber, sortData.isAsc])

  if (data.error) {
    return h(Message, data.error)
  }

  // Title and Subtitle
  let headerContent
  if (showTitleDescription === 'true') {
    headerContent = [
      data.workbookTitle && h(Heading, { _level: 3 }, data.workbookTitle),
      data.title && h(Heading, { _level: 4 }, prefix ? `${prefix} ${data.title}` : data.title),
    ]
  } else {
    headerContent = [data.title && h(Heading, { _level: 3 }, data.title)]
  }

  return data.ready && h(Box, { className: 'bg-bga text-txa rounded-2xl' },
    ...headerContent,
    h(Filter, { data, features, onQuery, id }),
    h(TableContents, { features, data, sortData, page, setPage, onColumnSort, id, 'aria-live': 'polite' }),
    showTitleDescription === 'true' && data.workbookDescription && h('p', {}, data.workbookDescription)
  )
}

register(StandaloneTable, 'standalone-table')
