import { fixture, assert, nextFrame, html, defineCE, unsafeStatic } from '@open-wc/testing'
import { TemplateResult } from 'lit'
import sinon from 'sinon'
import { sendKeys } from '@web/test-runner-commands'
import { DataTable, type EventCellInfo } from '../../../src/elements/data-table/DataTable.js'
import { LiveData } from '../../../src/reactive/LiveData.js'

class TestDataTable extends DataTable<TestItem> {
  override renderHeader(): TemplateResult[] {
    return [
      this.renderHeaderCell('C1', 0, { sort: 'name' }),
      this.renderHeaderCell('C2', 1, { ariaLabel: 'C2 label' }),
      this.renderHeaderCell('C3', 2, { classes: ['my-class'] }),
      this.renderHeaderCell(`C4`, 3, { delegateFocus: true }),
      this.renderHeaderCell(`C5`, 4, { data: { foo: 'bar' } }),
      this.renderHeaderCell(`C6`, 5, { sort: 'other' }),
    ]
  }
  override renderRowValues(item: TestItem, index: number): TemplateResult[] {
    return [
      this.renderCell(html`R${index}.1`, index, 0, { classes: ['primary-cell'], isPrimary: true }),
      this.renderCell(html`R${index}.2`, index, 1, { ariaLabel: `R${index}.2 Label` }),
      this.renderCell(html`R${index}.3`, index, 2, { classes: ['my-class'] }),
      this.renderCell(html`<button name="r.${index}.4" value="R${index}.4" tabindex="-1">Demo</button>`, index, 3, {
        delegateFocus: true,
      }),
      this.renderCell(html`R${index}.5`, index, 4, { data: { foo: 'row-bar' } }),
      this.renderCell(html`R${index}.6`, index, 5),
    ]
  }

  override infoFromEvent(e: Event): EventCellInfo | undefined {
    return super.infoFromEvent(e)
  }
}

const tagName = defineCE(TestDataTable)
const tag = unsafeStatic(tagName)

interface TestItem {
  name: string
  value: number
}

function generateItems(size = 5): LiveData<TestItem[]> {
  const result: TestItem[] = []
  for (let i = 0; i < size; i++) {
    result.push({
      name: `v${i}`,
      value: i,
    })
  }
  return new LiveData(result)
}

describe('DataTable', () => {
  async function basicFixture<T>(items: LiveData<T[]>): Promise<TestDataTable> {
    return fixture(html`<${tag} .items="${items}"></${tag}>`)
  }

  async function singleSelectionFixture<T>(items: LiveData<T[]>): Promise<TestDataTable> {
    return fixture(html`<${tag} .items="${items}" selection="single"></${tag}>`)
  }

  async function sortedFixture<T>(items: LiveData<T[]>): Promise<TestDataTable> {
    return fixture(html`<${tag} .items="${items}" sort="name"></${tag}>`)
  }

  async function multiSelectionFixture<T>(items: LiveData<T[]>): Promise<TestDataTable> {
    return fixture(html`<${tag} .items="${items}" selection="multi"></${tag}>`)
  }

  describe('Basic tests', () => {
    it('renders the table with correct number of rows and columns', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()
      const rows = element.shadowRoot!.querySelectorAll('tr')
      assert.equal(rows.length, items.value.length + 1, 'has all rows') // +1 for header row

      const headerCells = element.shadowRoot!.querySelectorAll('thead th')
      assert.equal(headerCells.length, 6, 'has all header cells')

      const firstRowCells = rows[1].querySelectorAll('td, th')
      assert.equal(firstRowCells.length, 6, 'has all row cells')
    })

    it('renders the empty state when there are no items', async () => {
      const element = await basicFixture<TestItem>(new LiveData([]))
      await nextFrame()

      const emptyState = element.shadowRoot!.querySelector('.empty')
      assert.exists(emptyState)
      assert.equal(emptyState!.textContent, 'No data to display.')
    })

    it('dispatches a select event when an item is selected', async () => {
      const items = generateItems(2)
      const element = await singleSelectionFixture<TestItem>(items)
      await nextFrame()

      const selectSpy = sinon.spy()
      element.addEventListener('select', selectSpy)

      const cell = element.shadowRoot!.querySelector(`[data-col="0"][data-row="1"]`) as HTMLElement
      cell.click()
      await nextFrame()

      assert.isTrue(selectSpy.calledOnce)
      assert.deepEqual(selectSpy.firstCall.args[0].detail.selectedItems, [items.value[0]])
    })

    it('dispatches an activate event when an item is activated (primary colum)', async () => {
      const items = generateItems(2)
      const element = await basicFixture<TestItem>(items)
      element.active = true
      await nextFrame()

      const activateSpy = sinon.spy()
      element.addEventListener('activate', activateSpy)

      const event = new MouseEvent('click', { detail: 2, bubbles: true, composed: true })
      const cell = element.shadowRoot!.querySelector(`[data-col="0"][data-row="1"]`) as HTMLElement
      cell.dispatchEvent(event)
      await nextFrame()

      assert.isTrue(activateSpy.calledOnce)
      assert.deepEqual(activateSpy.firstCall.args[0].detail, items.value[0])
    })

    it('dispatches an activate event when an item is activated (any colum)', async () => {
      const items = generateItems(2)
      const element = await basicFixture<TestItem>(items)
      element.active = true
      await nextFrame()

      const activateSpy = sinon.spy()
      element.addEventListener('activate', activateSpy)

      const event = new MouseEvent('click', { detail: 2, bubbles: true, composed: true })
      const cell = element.shadowRoot!.querySelector(`[data-col="2"][data-row="1"]`) as HTMLElement
      cell.dispatchEvent(event)
      await nextFrame()

      assert.isTrue(activateSpy.calledOnce)
      assert.deepEqual(activateSpy.firstCall.args[0].detail, items.value[0])
    })

    it('dispatches a sort event when a header is clicked', async () => {
      const items = generateItems(2)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const sortSpy = sinon.spy()
      element.addEventListener('sort', sortSpy)
      const headerCell = element.shadowRoot!.querySelectorAll('th')[0]
      headerCell.click()
      await nextFrame()

      assert.isTrue(sortSpy.calledOnce)
      assert.deepEqual(sortSpy.firstCall.args[0].detail, { column: 'name', direction: 'asc' })
    })

    it('toggle the sorting direction when the same header is clicked', async () => {
      const items = generateItems(2)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const sortSpy = sinon.spy()
      element.addEventListener('sort', sortSpy)
      const headerCell = element.shadowRoot!.querySelectorAll('th')[0]
      headerCell.click()
      await nextFrame()
      assert.deepEqual(sortSpy.firstCall.args[0].detail, { column: 'name', direction: 'asc' })
      headerCell.click()
      await nextFrame()

      assert.deepEqual(sortSpy.secondCall.args[0].detail, { column: 'name', direction: 'desc' })
    })

    it('dispatches a scrollend event when the sentinel is visible', async () => {
      const items = generateItems(2)
      const element = await basicFixture<TestItem>(items)
      element.overflow = true
      await nextFrame()

      const scrollendSpy = sinon.spy()
      element.addEventListener('scrollend', scrollendSpy)

      // Simulate the sentinel intersecting
      const sentinel = element.shadowRoot!.querySelector('.scroll-sentinel')!
      const entries = [
        {
          isIntersecting: true,
          target: sentinel,
        },
      ] as unknown as IntersectionObserverEntry[]
      // @ts-expect-error Used for testing
      element.handleIntersection(entries)

      assert.isTrue(scrollendSpy.calledOnce)
    })

    it('renders header cells with correct attributes and content', async () => {
      const items = generateItems(3)
      const element = await sortedFixture<TestItem>(items)
      await nextFrame()

      const headerCells = element.shadowRoot!.querySelectorAll('th')

      const sortedColumn = `<div class="cell-content">
        C1
        <md-icon
          aria-hidden="true"
          class="sort-icon"
          role="presentation"
        >
          arrow_downward
        </md-icon>`

      assert.dom.equal(headerCells[0].querySelector('.cell-content'), sortedColumn)
      assert.equal(headerCells[0].dataset.sort, 'name')
      assert.equal(headerCells[0].getAttribute('aria-sort'), 'descending')

      assert.equal(headerCells[1].textContent?.trim(), 'C2')
      assert.equal(headerCells[1].getAttribute('aria-label'), 'C2 label')

      assert.equal(headerCells[2].textContent?.trim(), 'C3')
      assert.isTrue(headerCells[2].classList.contains('my-class'))

      assert.equal(headerCells[3].textContent?.trim(), 'C4')
      assert.equal(headerCells[3].dataset.delegate, 'true')

      assert.equal(headerCells[4].textContent?.trim(), 'C5')
      assert.equal(headerCells[4].dataset.foo, 'bar')

      assert.equal(headerCells[5].textContent?.trim(), 'C6')
      assert.equal(headerCells[5].dataset.sort, 'other')
    })

    it('renders row cells with correct attributes and content', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const firstRow = element.shadowRoot!.querySelectorAll('tr')[1]
      const rowCells = firstRow.querySelectorAll('td, th') as NodeListOf<HTMLElement>

      assert.equal(rowCells[0].textContent?.trim(), 'R1.1')
      assert.isTrue(rowCells[0].classList.contains('primary-cell'))

      assert.equal(rowCells[1].textContent?.trim(), 'R1.2')
      assert.equal(rowCells[1].getAttribute('aria-label'), 'R1.2 Label')

      assert.equal(rowCells[2].textContent?.trim(), 'R1.3')
      assert.isTrue(rowCells[2].classList.contains('my-class'))

      assert.equal(rowCells[3].querySelector('button')?.value, 'R1.4')
      assert.equal(rowCells[3].dataset.delegate, 'true')

      assert.equal(rowCells[4].textContent?.trim(), 'R1.5')
      assert.equal(rowCells[4].dataset.foo, 'row-bar')

      assert.equal(rowCells[5].textContent?.trim(), 'R1.6')
    })
  })

  describe('a11y', () => {
    it('is accessible for basic table', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()
      await assert.isAccessible(element)
    })

    it('is accessible for basic single selection table', async () => {
      const items = generateItems(3)
      const element = await singleSelectionFixture<TestItem>(items)
      await nextFrame()
      await assert.isAccessible(element)
    })

    it('is accessible for basic sorted table', async () => {
      const items = generateItems(3)
      const element = await sortedFixture<TestItem>(items)
      await nextFrame()
      await assert.isAccessible(element)
    })

    it('is accessible for basic multi selection table', async () => {
      const items = generateItems(3)
      const element = await multiSelectionFixture<TestItem>(items)
      await nextFrame()
      await assert.isAccessible(element)
    })

    it('should have correct aria attributes for single selection', async () => {
      const items = generateItems(2)
      const element = await singleSelectionFixture<TestItem>(items)
      await nextFrame()

      // Check table attributes
      const table = element.shadowRoot!.querySelector('table')
      assert.equal(table?.getAttribute('aria-multiselectable'), null)

      // Simulate a selection
      const cell = element.shadowRoot!.querySelector(`[data-col="0"][data-row="1"]`) as HTMLElement
      cell.click()
      await nextFrame()

      // Check selected row
      const row = element.shadowRoot!.querySelector('tr[data-index="0"]')
      assert.equal(row?.getAttribute('aria-selected'), 'true')

      await assert.isAccessible(element)
    })

    it('should have correct aria attributes for multi selection', async () => {
      const items = generateItems(3)
      const element = await multiSelectionFixture<TestItem>(items)
      await nextFrame()

      // Check table attributes
      const table = element.shadowRoot!.querySelector('table')
      assert.equal(table?.getAttribute('aria-multiselectable'), 'true')

      // Simulate a selection
      const cell = element.shadowRoot!.querySelector(`[data-col="0"][data-row="1"]`) as HTMLElement
      cell.click()
      await nextFrame()

      // Check selected row
      const row = element.shadowRoot!.querySelector('tr[data-index="0"]')
      assert.equal(row?.getAttribute('aria-selected'), 'true')

      // Simulate another selection
      const cell2 = element.shadowRoot!.querySelector(`[data-col="0"][data-row="2"]`) as HTMLElement
      cell2.click()
      await nextFrame()

      // Check selected row
      const row2 = element.shadowRoot!.querySelector('tr[data-index="1"]')
      assert.equal(row2?.getAttribute('aria-selected'), 'true')

      await assert.isAccessible(element)
    })

    it('should have correct aria attributes for sorting', async () => {
      const items = generateItems(2)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      // Initial state: aria-sort should be "none"
      const headerCell = element.shadowRoot!.querySelectorAll('th')[0]
      assert.equal(headerCell.getAttribute('aria-sort'), 'none')

      // Simulate a sort
      headerCell.click()
      await nextFrame()

      // After first click, aria-sort should be "ascending"
      assert.equal(headerCell.getAttribute('aria-sort'), 'ascending')

      // Simulate another sort
      headerCell.click()
      await nextFrame()

      // After second click, aria-sort should be "descending"
      assert.equal(headerCell.getAttribute('aria-sort'), 'descending')

      await assert.isAccessible(element)
    })

    it('should have a descriptive aria-label for the table', async () => {
      const items = generateItems(2)
      const element = await basicFixture<TestItem>(items)
      element.ariaLabel = 'My Custom Table'
      await nextFrame()

      const table = element.shadowRoot!.querySelector('table')
      assert.equal(table?.getAttribute('aria-label'), 'My Custom Table')

      await assert.isAccessible(element)
    })

    it('should have role="row" on each table row', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const rows = element.shadowRoot!.querySelectorAll('tr')
      rows.forEach((row) => {
        assert.equal(row.getAttribute('role'), 'row')
      })
    })

    it('should have role="columnheader" on each header cell', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const headerCells = element.shadowRoot!.querySelectorAll('thead th')
      headerCells.forEach((headerCell) => {
        assert.equal(headerCell.getAttribute('role'), 'columnheader')
        assert.equal(headerCell.getAttribute('scope'), 'col')
      })
    })

    it('should have role="gridcell" on each data cell', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const dataCells = element.shadowRoot!.querySelectorAll('tbody td, tbody th')
      dataCells.forEach((dataCell) => {
        assert.equal(dataCell.getAttribute('role'), 'gridcell')
      })
    })

    it('should focus on the first header cell on init', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const firstHeaderCell = element.shadowRoot!.querySelector(`[data-col="0"][data-row="0"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, firstHeaderCell)
    })

    it('should navigate with arrow keys', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const firstHeaderCell = element.shadowRoot!.querySelector(`[data-col="0"][data-row="0"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, firstHeaderCell)

      await sendKeys({ press: 'ArrowRight' })
      await nextFrame()

      const secondHeaderCell = element.shadowRoot!.querySelector(`[data-col="1"][data-row="0"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, secondHeaderCell, 'the second cell is focused')

      await sendKeys({ press: 'ArrowDown' })
      await nextFrame()

      const firstCell = element.shadowRoot!.querySelector(`[data-col="1"][data-row="1"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, firstCell, 'the first cell in row two is focused')
    })

    it('should navigate with arrow keys and delegates the focus correctly', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const c1 = element.shadowRoot!.querySelector(`[data-col="0"][data-row="0"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, c1, 'focuses on the first header cell')

      await sendKeys({ press: 'ArrowDown' })
      await nextFrame() // -> r1,c0
      await sendKeys({ press: 'ArrowRight' })
      await nextFrame() // -> r1,c1
      await sendKeys({ press: 'ArrowRight' })
      await nextFrame() // -> r1,c2
      await sendKeys({ press: 'ArrowRight' })
      await nextFrame() // -> r1,c3
      await element.updateComplete

      await sendKeys({ press: 'ArrowDown' })
      await nextFrame() // -> r2,c3

      const button = element
        .shadowRoot!.querySelector(`[data-col="3"][data-row="2"]`)
        ?.querySelector('button') as HTMLElement
      assert.dom.equal(button, '<button name="r.2.4" value="R2.4" tabindex="-1">Demo</button>')
    })

    it('should navigate with PageUp/PageDown', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const c1 = element.shadowRoot!.querySelector(`[data-col="0"][data-row="0"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, c1)

      await sendKeys({ press: 'ArrowDown' })
      await nextFrame() // -> r1c0
      await sendKeys({ press: 'ArrowDown' })
      await nextFrame() // -> r2c0

      const c2 = element.shadowRoot!.querySelector(`[data-col="0"][data-row="2"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, c2, 'moved to the second row')

      await sendKeys({ press: 'PageUp' })
      await nextFrame() // -> r1c0

      const c3 = element.shadowRoot!.querySelector(`[data-col="0"][data-row="1"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, c3, 'focused on the first row')

      await sendKeys({ press: 'PageDown' })
      await nextFrame() // -> r3c0

      const lastRowCell = element.shadowRoot!.querySelector(`[data-col="0"][data-row="3"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, lastRowCell, 'focuses on the last row')
    })

    it('should navigate with Home/End', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const c1 = element.shadowRoot!.querySelector(`[data-col="0"][data-row="0"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, c1)

      await sendKeys({ press: 'ArrowDown' })
      await nextFrame() // -> r1c0
      await sendKeys({ press: 'ArrowRight' })
      await nextFrame() // -> r1c1
      await sendKeys({ press: 'ArrowRight' })
      await nextFrame() // -> r1c2

      const c2 = element.shadowRoot!.querySelector(`[data-col="2"][data-row="1"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, c2, 'moved to the second row, cell 2')

      await sendKeys({ press: 'Home' })
      await nextFrame() // r1c0

      const c3 = element.shadowRoot!.querySelector(`[data-col="0"][data-row="1"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, c3, 'goes back to the first column')

      await sendKeys({ press: 'End' })
      await nextFrame() // r1c5

      const c4 = element.shadowRoot!.querySelector(`[data-col="5"][data-row="1"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, c4, 'focuses on the last cell in the row')

      await sendKeys({ press: 'Control+Home' })
      await nextFrame() // r1c0

      const c5 = element.shadowRoot!.querySelector(`[data-col="0"][data-row="1"]`) as HTMLElement
      assert.equal(element.shadowRoot!.activeElement, c5, 'focuses on the first cell in the table')
    })
  })

  describe('isSelected', () => {
    it('should return true if the item is selected', async () => {
      const items = generateItems(3)
      const element = await multiSelectionFixture<TestItem>(items)
      await nextFrame()
      element.selectedItems = [items.value[1]]
      assert.isTrue(element.isSelected(items.value[1]))
    })

    it('should return false if the item is not selected', async () => {
      const items = generateItems(3)
      const element = await multiSelectionFixture<TestItem>(items)
      await nextFrame()

      element.selectedItems = [items.value[1]]
      assert.isFalse(element.isSelected(items.value[0]))
    })
  })

  describe('infoFromEvent()', () => {
    it('should return cell info when clicking on a td', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const cell = element.shadowRoot!.querySelector(`[data-row="2"][data-col="2"]`) as HTMLElement
      const event = new MouseEvent('click', { bubbles: true, composed: true })
      const spy = sinon.spy(element, 'infoFromEvent')
      cell.dispatchEvent(event)

      assert.isTrue(spy.calledOnce)
      const info = spy.returnValues[0] as EventCellInfo

      // assert.dom.equal(info.cell, cell)
      assert.ok(info.cell, 'has the cell reference')
      assert.equal(info?.col, 2)
      assert.equal(info?.row, 2)
    })

    it('should return cell info when clicking on a th', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const cell = element.shadowRoot!.querySelector(`[data-row="0"][data-col="1"]`) as HTMLElement
      const event = new MouseEvent('click', { bubbles: true, composed: true })
      const spy = sinon.spy(element, 'infoFromEvent')
      cell.dispatchEvent(event)

      assert.isTrue(spy.calledOnce)
      const info = spy.returnValues[0] as EventCellInfo

      // assert.dom.equal(info.cell, cell)
      assert.ok(info.cell, 'has the cell reference')
      assert.equal(info?.row, 0)
      assert.equal(info?.col, 1)
    })

    it('should return undefined when clicking outside a cell', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const event = new MouseEvent('click', { bubbles: true, composed: true })
      const spy = sinon.spy(element, 'infoFromEvent')
      element.dispatchEvent(event)

      assert.isFalse(spy.called)
    })

    it('should return undefined when clicking on an interactive element within a cell', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      const button = element.shadowRoot!.querySelector(`[data-col="3"][data-row="2"] button`) as HTMLElement
      const event = new MouseEvent('click', { bubbles: true, composed: true })
      const spy = sinon.spy(element, 'infoFromEvent')
      button.dispatchEvent(event)

      assert.isTrue(spy.calledOnce)
      const info = spy.returnValues[0] as EventCellInfo | undefined

      assert.isUndefined(info)
    })

    it('should return undefined when the col or row are invalid', async () => {
      const items = generateItems(3)
      const element = await basicFixture<TestItem>(items)
      await nextFrame()

      // Simulate an event on a malformed cell (missing data-col or data-row)
      const fakeCell = document.createElement('td')
      const event = new MouseEvent('click', { bubbles: true, composed: true })
      const spy = sinon.spy(element, 'infoFromEvent')
      const table = element.shadowRoot?.querySelector('table') as HTMLTableElement
      table.appendChild(fakeCell)
      fakeCell.dispatchEvent(event)

      assert.isTrue(spy.calledOnce)
      const info = spy.returnValues[0] as EventCellInfo | undefined

      assert.isUndefined(info)
    })
  })
})
