/* eslint-disable @typescript-eslint/no-unused-expressions */
import { fixture, expect, html, oneEvent, nextFrame, aTimeout, assert } from '@open-wc/testing'
import sinon from 'sinon'

import { AutocompleteInput } from '../../../src/elements/autocomplete/autocomplete-input.js'
import '../../../src/elements/autocomplete/autocomplete-input.js' // Registers the element

import '../../../src/md/list/ui-list-item.js'
import '../../../src/md/listbox/ui-listbox.js'

import type MdListItem from '../../../src/md/list/internals/ListItem.js'
import type MdListbox from '../../../src/md/listbox/internals/Listbox.js'

describe('AutocompleteInput', () => {
  async function basicFixture(): Promise<AutocompleteInput> {
    return fixture(html`
      <autocomplete-input>
        <input id="test-input" slot="input" type="text" placeholder="Search..." />
        <ui-listbox slot="suggestions" aria-label="Suggestions">
          <ui-list-item data-value="apple">Apple</ui-list-item>
          <ui-list-item data-value="banana">Banana</ui-list-item>
          <ui-list-item data-value="cherry" data-index="value customField" data-custom-field="Sweet Cherry"
            >Cherry</ui-list-item
          >
        </ui-listbox>
      </autocomplete-input>
    `)
  }

  async function noSuggestionsFixture(): Promise<AutocompleteInput> {
    return fixture(html`
      <autocomplete-input>
        <input slot="input" type="text" placeholder="Search..." />
        <ui-listbox slot="suggestions" aria-label="Suggestions"></ui-listbox>
      </autocomplete-input>
    `)
  }

  async function noListboxFixture(): Promise<AutocompleteInput> {
    return fixture(html`
      <autocomplete-input>
        <input slot="input" type="text" placeholder="Search..." />
      </autocomplete-input>
    `)
  }

  function getSlottedInput(el: AutocompleteInput): HTMLInputElement | null {
    return el.querySelector('[slot="input"]') as HTMLInputElement | null
  }

  function getSlottedSuggestions(el: AutocompleteInput): MdListbox | null {
    return el.querySelector('[slot="suggestions"]') as MdListbox | null
  }

  function getSuggestionItems(suggestionsEl: MdListbox): MdListItem[] {
    // Assuming MdListbox.items returns the slotted items that are MdListItem
    return (suggestionsEl.items as MdListItem[]).filter(
      (item) => item instanceof HTMLElement && item.matches('ui-list-item')
    )
  }

  describe('Initialization', () => {
    it('correctly identifies slotted input and suggestions', async () => {
      const el = await basicFixture()
      await el.updateComplete // Ensure firstUpdated has run

      const input = getSlottedInput(el)
      const suggestions = getSlottedSuggestions(el)

      expect(input).to.exist
      expect(input?.id).to.equal('test-input')
      expect(suggestions).to.exist
      expect(suggestions?.popover).to.equal('manual')
    })

    it('generates an ID for the input if not provided and sets up anchor names', async () => {
      const el = (await fixture(html`
        <autocomplete-input>
          <input slot="input" type="text" />
          <ui-listbox slot="suggestions"></ui-listbox>
        </autocomplete-input>
      `)) as AutocompleteInput
      await el.updateComplete
      await nextFrame() // Allow for internal state updates

      const input = getSlottedInput(el)!
      const suggestions = getSlottedSuggestions(el)!

      expect(input.id).to.match(/^autocomplete-input-/)
      // @ts-expect-error _inputId is a protected member
      const internalInputId = el.inputId
      expect(input.style.getPropertyValue('anchor-name')).to.equal(`--${internalInputId}`, 'input has anchor name')
      const anchorValue = getComputedStyle(suggestions).getPropertyValue('position-anchor')
      expect(anchorValue).to.equal(`--${internalInputId}`, 'suggestions element has position-anchor')
    })

    it('popover is initially closed', async () => {
      const el = await basicFixture()
      await el.updateComplete
      const suggestions = getSlottedSuggestions(el)
      expect(suggestions?.matches(':popover-open')).to.be.false
      expect(el.opened).to.be.false
    })
  })

  describe('Popover behavior', () => {
    it('opens on input focus with suggestions', async () => {
      const el = await basicFixture()
      await el.updateComplete
      const input = getSlottedInput(el)!
      const suggestions = getSlottedSuggestions(el)!

      input.focus()
      await nextFrame() // Allow focus event to propagate and popover to open

      expect(suggestions.matches(':popover-open')).to.be.true
      expect(el.opened).to.be.true
    })

    it('does not open on input focus without suggestions', async () => {
      const el = await noSuggestionsFixture()
      await el.updateComplete
      const input = getSlottedInput(el)!
      const suggestions = getSlottedSuggestions(el)!

      input.focus()
      await nextFrame()

      expect(suggestions.matches(':popover-open')).to.be.false
    })

    it('does not open on input focus if suggestions element is missing', async () => {
      const el = await noListboxFixture()
      await el.updateComplete
      const input = getSlottedInput(el)!

      input.focus()
      await nextFrame()
      // No suggestions ref, so opened should be false
      expect(el.opened).to.be.false
    })

    it('closes on input blur', async () => {
      const el = await basicFixture()
      await el.updateComplete
      const input = getSlottedInput(el)!
      const suggestions = getSlottedSuggestions(el)!

      input.focus()
      await nextFrame()
      expect(suggestions.matches(':popover-open')).to.be.true

      input.blur()
      // Blur handler uses requestAnimationFrame
      await aTimeout(0) // Wait for rAF
      await nextFrame() // Wait for potential re-render

      expect(suggestions.matches(':popover-open')).to.be.false
      expect(el.opened).to.be.false
    })

    it('closes on Escape key', async () => {
      const el = await basicFixture()
      await el.updateComplete
      const input = getSlottedInput(el)!
      const suggestions = getSlottedSuggestions(el)!

      input.focus()
      await nextFrame()
      expect(suggestions.matches(':popover-open')).to.be.true

      input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true }))
      await nextFrame()

      expect(suggestions.matches(':popover-open')).to.be.false
    })
  })

  describe('Filtering', () => {
    let el: AutocompleteInput
    let input: HTMLInputElement
    let suggestionsBox: MdListbox
    let items: MdListItem[]

    beforeEach(async () => {
      el = await basicFixture()
      await el.updateComplete
      input = getSlottedInput(el)!
      suggestionsBox = getSlottedSuggestions(el)!
      items = getSuggestionItems(suggestionsBox)
      input.focus() // Open popover for filtering tests
      await nextFrame()
    })

    it('shows all items for empty query', () => {
      input.value = ''
      input.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
      items.forEach((item) => expect(item.hidden).to.be.false)
    })

    it('filters items based on data-value', async () => {
      input.value = 'app'
      input.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
      await nextFrame()

      expect(items[0].hidden).to.be.false // Apple
      expect(items[1].hidden).to.be.true // Banana
      expect(items[2].hidden).to.be.true // Cherry
    })

    it('filters items based on textContent if data-value is not present or does not match', async () => {
      // Modify first item to not have data-value for this test
      items[0].removeAttribute('data-value')
      items[0].textContent = 'Pineapple'
      await nextFrame()

      input.value = 'pine'
      input.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
      await nextFrame()

      expect(items[0].hidden).to.be.false // Pineapple (via textContent)
      expect(items[1].hidden).to.be.true // Banana
      expect(items[2].hidden).to.be.true // Cherry
    })

    it('filters items based on data-index fields', async () => {
      input.value = 'Sweet' // Matches data-custom-field="Sweet Cherry" via data-index on items[2]
      input.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
      await nextFrame()

      expect(items[0].hidden).to.be.true // Apple
      expect(items[1].hidden).to.be.true // Banana
      expect(items[2].hidden).to.be.false // Cherry
    })

    it('is case-insensitive', async () => {
      input.value = 'BaNaNa'
      input.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
      await nextFrame()

      expect(items[0].hidden).to.be.true // Apple
      expect(items[1].hidden).to.be.false // Banana
      expect(items[2].hidden).to.be.true // Cherry
    })

    it('closes popover if no items match', async () => {
      input.value = 'nonexistent'
      input.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
      await nextFrame()
      await aTimeout(0) // filterSuggestions might call closeSuggestions, which might be async

      items.forEach((item) => expect(item.hidden).to.be.true)
      expect(suggestionsBox.matches(':popover-open')).to.be.false
    })
  })

  describe('Keyboard navigation', () => {
    let el: AutocompleteInput
    let input: HTMLInputElement
    let suggestionsBox: MdListbox

    beforeEach(async () => {
      el = await basicFixture()
      await el.updateComplete
      input = getSlottedInput(el)!
      suggestionsBox = getSlottedSuggestions(el)!
      // For keyboard nav, listbox needs to handle highlight internally.
      // We spy on its methods.
      sinon.spy(suggestionsBox, 'highlightNext')
      sinon.spy(suggestionsBox, 'highlightPrevious')
      sinon.spy(suggestionsBox, 'notifySelect')
    })

    afterEach(() => {
      sinon.restore()
    })

    it('ArrowDown highlights next item and opens popover if closed', async () => {
      expect(suggestionsBox.matches(':popover-open')).to.be.false
      input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, composed: true }))
      // handleKeydown uses rAF if popover was closed
      await aTimeout(0) // for rAF
      await nextFrame() // for popover to open and highlight

      expect(suggestionsBox.matches(':popover-open')).to.be.true
      expect(suggestionsBox.highlightNext).to.have.been.calledOnce
    })

    it('ArrowUp highlights previous item', async () => {
      input.focus() // Open popover
      await nextFrame()
      input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, composed: true }))
      await nextFrame()
      expect(suggestionsBox.highlightPrevious).to.have.been.calledOnce
    })

    it('Enter selects highlighted item and dispatches event', async () => {
      input.focus() // Open popover
      await nextFrame()

      // Simulate item being highlighted by listbox (e.g., first item)
      const items = getSuggestionItems(suggestionsBox)
      suggestionsBox.highlightListItem = items[0]
      await nextFrame()

      const eventPromise = oneEvent(el, 'autocomplete')
      input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true }))

      const event = await eventPromise
      expect(suggestionsBox.notifySelect).to.have.been.calledWith(items[0])
      expect(event.detail.item).to.equal(items[0])
      expect(suggestionsBox.matches(':popover-open')).to.be.false // Popover should close
    })

    it('Enter does nothing if popover closed or no item highlighted', async () => {
      expect(suggestionsBox.matches(':popover-open')).to.be.false
      suggestionsBox.highlightListItem = null // Ensure no item is highlighted

      const spy = sinon.spy()
      el.addEventListener('autocomplete', spy)

      input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true }))
      await nextFrame()

      expect(spy).not.to.have.been.called
      expect(suggestionsBox.notifySelect).not.to.have.been.called
    })
  })

  describe('Suggestion selection (click)', () => {
    it('selects item on click and dispatches event', async () => {
      const el = await basicFixture()
      await el.updateComplete
      const input = getSlottedInput(el)!
      const suggestionsBox = getSlottedSuggestions(el)!
      const items = getSuggestionItems(suggestionsBox)

      input.focus() // Open popover
      await nextFrame()

      const eventPromise = oneEvent(el, 'autocomplete')
      // Simulate click on the first item.
      // The `ui-listbox` should dispatch 'select' upon item click.
      // We simulate the 'select' event from the listbox.
      suggestionsBox.dispatchEvent(
        new CustomEvent('select', {
          detail: { item: items[1] }, // Select 'Banana'
          bubbles: true, // Ensure it bubbles to autocomplete-input if needed by its listener
          composed: true,
        })
      )

      const event = await eventPromise
      expect(event.detail.item).to.equal(items[1])
      expect(suggestionsBox.matches(':popover-open')).to.be.false // Popover should close
    })
  })

  describe('Dynamic content', () => {
    it('handles dynamically added input', async () => {
      const el = await fixture<AutocompleteInput>(html`<autocomplete-input></autocomplete-input>`)
      await el.updateComplete

      const inputEl = document.createElement('input')
      inputEl.slot = 'input'
      inputEl.type = 'text'
      el.appendChild(inputEl)
      await nextFrame() // MutationObserver is async
      await el.updateComplete // Ensure component processes the new input

      const slottedInput = getSlottedInput(el)
      expect(slottedInput).to.equal(inputEl)
      expect(inputEl.id).to.match(/^autocomplete-input-/)
    })

    it('handles dynamically added suggestions', async () => {
      const el = await fixture<AutocompleteInput>(
        html`<autocomplete-input><input slot="input" id="dyn-input" /></autocomplete-input>`
      )
      await el.updateComplete
      const input = getSlottedInput(el)!

      const suggestionsEl = document.createElement('ui-listbox') as MdListbox
      suggestionsEl.slot = 'suggestions'
      el.appendChild(suggestionsEl)
      await nextFrame() // MutationObserver
      await el.updateComplete

      const slottedSuggestions = getSlottedSuggestions(el)
      expect(slottedSuggestions).to.equal(suggestionsEl)
      expect(suggestionsEl.popover).to.equal('manual')
      const anchorValue = getComputedStyle(suggestionsEl).getPropertyValue('position-anchor')
      expect(anchorValue).to.equal(`--${input.id}`)
    })

    it('re-filters when suggestion items change (via itemschange event)', async () => {
      const el = await basicFixture()
      await el.updateComplete
      const input = getSlottedInput(el)!
      const suggestionsBox = getSlottedSuggestions(el)!
      let items = getSuggestionItems(suggestionsBox)

      input.focus()
      input.value = 'ban' // Should show "Banana"
      input.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
      await nextFrame()
      expect(items[0].hidden).to.be.true // Apple
      expect(items[1].hidden).to.be.false // Banana

      // Add a new item that matches "ban"
      const newItem = document.createElement('ui-list-item') as MdListItem
      newItem.dataset.value = 'bandana'
      newItem.textContent = 'Bandana'
      suggestionsBox.appendChild(newItem)

      // Simulate 'itemschange' event from listbox
      suggestionsBox.dispatchEvent(new CustomEvent('itemschange'))
      await nextFrame() // Allow handler to run

      items = getSuggestionItems(suggestionsBox) // Re-fetch items
      expect(items.find((i) => i.dataset.value === 'bandana')?.hidden).to.be.false
      expect(items.find((i) => i.dataset.value === 'banana')?.hidden).to.be.false
      expect(items.find((i) => i.dataset.value === 'apple')?.hidden).to.be.true
    })
  })

  describe('`opened` property', () => {
    it('reflects popover state', async () => {
      const el = await basicFixture()
      await el.updateComplete
      const input = getSlottedInput(el)!
      const suggestions = getSlottedSuggestions(el)!

      expect(el.opened).to.be.false

      input.focus()
      await nextFrame()
      expect(el.opened).to.be.true
      expect(suggestions.matches(':popover-open')).to.be.true

      // @ts-expect-error protected method
      el.closeSuggestions()
      await nextFrame()
      expect(el.opened).to.be.false
      expect(suggestions.matches(':popover-open')).to.be.false
    })
  })

  describe('Popover Positioning', () => {
    let el: AutocompleteInput
    let input: HTMLInputElement
    let suggestionsBox: MdListbox
    let getBoundingClientRectStub: sinon.SinonStub

    // Define a standard popover height for consistent threshold calculation
    const popoverVisibleHeight = 200 // px, used for scrollHeight

    beforeEach(async () => {
      el = await basicFixture() // This fixture has suggestions
      await el.updateComplete // Ensures inputRef is set up
      input = getSlottedInput(el)!
      suggestionsBox = getSlottedSuggestions(el)!

      // Set a known height for the suggestions box to make its scrollHeight predictable
      suggestionsBox.style.height = `${popoverVisibleHeight}px`
      suggestionsBox.style.display = 'block' // Ensure it's not display:none from other CSS
      suggestionsBox.style.overflow = 'auto'
      // Ensure it has some content to have a scrollHeight if not stubbed
      if (getSuggestionItems(suggestionsBox).length === 0) {
        const item = document.createElement('ui-list-item')
        item.textContent = 'Dummy Item'
        suggestionsBox.appendChild(item)
      }
      await nextFrame() // Allow DOM to update for scrollHeight calculation

      // @ts-expect-error accessing protected member `inputRef`
      const anchorEl = el.inputRef as HTMLElement // Default anchor
      if (!anchorEl) {
        throw new Error('el.inputRef was not initialized')
      }
      getBoundingClientRectStub = sinon.stub(anchorEl, 'getBoundingClientRect')
    })

    afterEach(() => {
      sinon.restore() // Restores window.innerHeight and the getBoundingClientRectStub
    })

    function setAnchorPosition(
      anchorRect: Partial<DOMRect & { height: number; width: number }>,
      viewportHeight: number
    ) {
      getBoundingClientRectStub.returns(anchorRect as DOMRect)
      // Stub window.innerHeight for this call, will be restored by sinon.restore() in afterEach
      sinon.stub(window, 'innerHeight').get(() => viewportHeight)
    }

    it('positions at bottom when ample space below', async () => {
      // Anchor near top, viewport large. popoverThresholdHeight will be popoverVisibleHeight (200)
      setAnchorPosition({ top: 50, bottom: 80, height: 30, x: 0, y: 50, width: 100, left: 0, right: 100 }, 800)
      // spaceBelow = 800 - 80 = 720. 720 > 200.

      input.focus() // Triggers openSuggestions
      await el.updateComplete // For positionArea state change and re-render
      await nextFrame() // For DOM to reflect changes

      expect(el.positionArea).to.equal('bottom')
    })

    it('positions at top when insufficient space below but sufficient space above', async () => {
      // popoverThresholdHeight = 200
      // Anchor bottom: 750. Viewport: 800. spaceBelow = 50. (50 < 200)
      // Anchor top: 500. spaceAbove = 500. (500 > 50) && (500 > 200) -> true
      setAnchorPosition({ top: 500, bottom: 750, height: 250, x: 0, y: 500, width: 100, left: 0, right: 100 }, 800)

      input.focus()
      await el.updateComplete
      await nextFrame()

      expect(el.positionArea).to.equal('top')
    })

    it('positions at top when insufficient space below and more space above', async () => {
      // popoverThresholdHeight = 200
      // spaceBelow = 50 (anchor.bottom = viewportHeight - 50)
      // spaceAbove = 100 (anchor.top = 100)
      // viewportHeight = anchor.top + anchor.height + spaceBelow = 100 + 30 + 50 = 180
      // Test case: spaceBelow=50, spaceAbove=100. popoverThresholdHeight=200.
      // (50 < 200) is true.
      // (100 > 50) is true.
      // (100 > 200) is false. -> first 'top' condition fails.
      // Second 'top' condition: (spaceBelow < popoverThresholdHeight && spaceAbove > spaceBelow) -> true.
      setAnchorPosition({ top: 100, bottom: 130, height: 30, x: 0, y: 100, width: 100, left: 0, right: 100 }, 180)

      input.focus()
      await el.updateComplete
      await nextFrame()

      expect(el.positionArea).to.equal('top')
    })

    it('positions at bottom when insufficient space below and also insufficient (or less) space above', async () => {
      // popoverThresholdHeight = 200
      // spaceBelow = 50 (anchor.bottom = viewportHeight - 50)
      // spaceAbove = 40 (anchor.top = 40)
      // viewportHeight = anchor.top + anchor.height + spaceBelow = 40 + 30 + 50 = 120
      // Test case: spaceBelow=50, spaceAbove=40.
      // (50 < 200) is true.
      // (40 > 50) is false. -> both 'top' conditions fail. Defaults to 'bottom'.
      setAnchorPosition({ top: 40, bottom: 70, height: 30, x: 0, y: 40, width: 100, left: 0, right: 100 }, 120)

      input.focus()
      await el.updateComplete
      await nextFrame()

      expect(el.positionArea).to.equal('bottom')
    })

    it('uses fallback threshold of 150px if popover scrollHeight is 0', async () => {
      // Stub scrollHeight to be 0 for this test.
      const scrollHeightStub = sinon.stub(suggestionsBox, 'scrollHeight').get(() => 0)

      // popoverThresholdHeight will be 150 (the fallback).
      // Scenario: spaceBelow = 100, spaceAbove = 200.
      // (100 < 150) is true.
      // (200 > 100) is true. (200 > 150) is true. -> Should be 'top'.
      setAnchorPosition({ top: 500, bottom: 700, height: 200, x: 0, y: 500, width: 100, left: 0, right: 100 }, 800)
      // spaceBelow = 800 - 700 = 100
      // spaceAbove = 500 (mistake in manual calc above, should be 500)
      // Corrected: spaceBelow = 100. spaceAbove = 500. Threshold = 150.
      // (100 < 150) -> true
      // (500 > 100) -> true. (500 > 150) -> true. Result: 'top'.

      input.focus()
      await el.updateComplete
      await nextFrame()

      expect(el.positionArea).to.equal('top')
      scrollHeightStub.restore()
    })

    it('uses slotted anchor for positioning if provided', async () => {
      // Need to create a new fixture for this specific setup
      el = await fixture(html`
        <autocomplete-input>
          <div slot="anchor" id="custom-anchor" style="height: 20px; width: 100px; border: 1px solid red;"></div>
          <input slot="input" type="text" />
          <ui-listbox slot="suggestions" style="height: ${popoverVisibleHeight}px; overflow: auto;">
            <ui-list-item>Item 1</ui-list-item>
          </ui-listbox>
        </autocomplete-input>
      `)
      await el.updateComplete
      input = getSlottedInput(el)! // Still need to focus input
      const customAnchor = el.querySelector('#custom-anchor') as HTMLElement

      getBoundingClientRectStub.restore() // remove stub from default input anchor
      getBoundingClientRectStub = sinon.stub(customAnchor, 'getBoundingClientRect')

      // Scenario: custom anchor is near bottom, should open top. popoverThresholdHeight = 200.
      setAnchorPosition({ top: 720, bottom: 750, height: 30, x: 0, y: 720, width: 100, left: 0, right: 100 }, 800)

      input.focus() // Triggers openSuggestions
      await el.updateComplete
      await nextFrame()

      expect(el.positionArea).to.equal('top')
      expect(getBoundingClientRectStub.called).to.be.true
    })
  })

  describe('Accessibility', () => {
    it('is accessible when initially rendered', async () => {
      const el = await basicFixture()
      await el.updateComplete
      await assert.isAccessible(el)
    })

    it('is accessible when popover is open', async () => {
      const el = await basicFixture()
      await el.updateComplete
      const input = getSlottedInput(el)!

      input.focus() // Opens popover
      await nextFrame() // Allow popover to open
      await el.updateComplete // Ensure state updates related to opening are done

      await assert.isAccessible(el)
    })

    it('is accessible when popover is open and an item is highlighted', async () => {
      const el = await basicFixture()
      await el.updateComplete
      const input = getSlottedInput(el)!

      input.focus() // Opens popover
      await nextFrame()

      // Simulate highlighting the first item
      input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, composed: true }))
      await aTimeout(0) // for rAF in handleKeydown
      await nextFrame() // for highlight to apply
      await assert.isAccessible(el)
    })
  })
})
