/**
 * PopupSelectController behavior (design §10.2/§10.3): one options load per
 * open with local search filtering, filtered highlight movement,
 * single-flight select with open-time context, consume-on-success (CAS miss
 * benign), failure-keeps-open retry semantics for both options and onSelect,
 * and binding-identity revocation of late settlements after
 * dismiss/reopen/dispose.
 */
import { describe, expect, it, vi } from 'vitest'
import type { SelectOption } from '../src/client/contract.ts'
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
import { filterOptions, PopupSelectController } from '../src/client/popup.ts'

interface Ctx { readonly session: string }
const CTX_A: Ctx = { session: 'A' }

const OPTIONS: SelectOption[] = [
  { id: 'dark', label: 'Dark' },
  { id: 'light', label: 'Light', active: true },
  { id: 'sepia', label: 'Sepia', detail: 'warm' },
]

const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }

function spec(overrides: Partial<PopupSpec<Ctx>> = {}): PopupSpec<Ctx> {
  return {
    options: () => Promise.resolve(OPTIONS),
    onSelect: () => undefined,
    ...overrides,
  }
}

/** Fake session wiring: records consume/focus calls; consume answer is settable per test. */
function makeDeps(consumeResult = true) {
  const consume = vi.fn((_segment: TokenSegment) => consumeResult)
  const focusComposer = vi.fn()
  return { consume, focusComposer }
}

async function readyPopup(overrides: Partial<PopupSpec<Ctx>> = {}, deps = makeDeps()) {
  const popup = new PopupSelectController<Ctx>(deps)
  popup.open('theme', spec(overrides), CTX_A, SEGMENT)
  await Promise.resolve()
  return { popup, deps }
}

describe('filterOptions', () => {
  it('matches case-insensitively over label and detail; blank keeps all', () => {
    expect(filterOptions(OPTIONS, '')).toBe(OPTIONS)
    expect(filterOptions(OPTIONS, '  ')).toBe(OPTIONS)
    expect(filterOptions(OPTIONS, 'DARK')).toEqual([OPTIONS[0]])
    expect(filterOptions(OPTIONS, 'warm')).toEqual([OPTIONS[2]])
    expect(filterOptions(OPTIONS, 'nope')).toEqual([])
  })
})

describe('open and options load', () => {
  it('publishes pending immediately, ready when options land', async () => {
    const popup = new PopupSelectController<Ctx>(makeDeps())
    let release!: (options: readonly SelectOption[]) => void
    popup.open('theme', spec({ options: () => new Promise((resolve) => { release = resolve }) }), CTX_A, SEGMENT)
    expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'theme', status: 'pending', search: '', submitting: false, error: null })
    release(OPTIONS)
    await Promise.resolve()
    expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, active: 0 })
  })

  it('loads options exactly once: search filters locally without re-querying the provider', async () => {
    const options = vi.fn(() => Promise.resolve(OPTIONS))
    const { popup } = await readyPopup({ options })
    popup.setSearch('li')
    popup.setSearch('light')
    const s = popup.state.getSnapshot()
    expect(options).toHaveBeenCalledTimes(1)
    expect(s.options).toEqual(OPTIONS) // original array retained; filtering is view-side
    expect(s.search).toBe('light')
    expect(filterOptions(s.options, s.search)).toEqual([OPTIONS[1]])
  })

  it('a reopen aborts the old load and drops its late arrival', async () => {
    const popup = new PopupSelectController<Ctx>(makeDeps())
    let firstSignal!: AbortSignal
    let releaseFirst!: (options: readonly SelectOption[]) => void
    popup.open('alpha', spec({
      options: (_ctx, signal) => {
        firstSignal = signal
        return new Promise((resolve) => { releaseFirst = resolve })
      },
    }), CTX_A, SEGMENT)
    popup.open('beta', spec(), CTX_A, SEGMENT)
    expect(firstSignal.aborted).toBe(true)
    releaseFirst([{ id: 'stale', label: 'stale' }])
    await Promise.resolve()
    const s = popup.state.getSnapshot()
    expect(s.command).toBe('beta')
    expect(s.options).toEqual(OPTIONS)
  })

  it('dispose aborts the flying load, clears state, and drops the late arrival', async () => {
    const popup = new PopupSelectController<Ctx>(makeDeps())
    let signal!: AbortSignal
    let release!: (options: readonly SelectOption[]) => void
    popup.open('theme', spec({
      options: (_ctx, s) => {
        signal = s
        return new Promise((resolve) => { release = resolve })
      },
    }), CTX_A, SEGMENT)
    popup.dispose()
    expect(signal.aborted).toBe(true)
    expect(popup.state.getSnapshot().open).toBe(false)
    release(OPTIONS)
    await Promise.resolve()
    expect(popup.state.getSnapshot().open).toBe(false)
  })

  it('an options failure keeps the shell open with search retained, surfaces the error, and retry reloads', async () => {
    let attempts = 0
    const { popup } = await readyPopup({
      options: () => {
        attempts += 1
        return attempts === 1 ? Promise.reject(new Error('directory down')) : Promise.resolve(OPTIONS)
      },
    })
    await Promise.resolve()
    popup.setSearch('da')
    // The failure landed before setSearch (readyPopup awaited); search must survive it and retry.
    expect(popup.state.getSnapshot()).toMatchObject({ open: true, status: 'failed', error: 'directory down', search: 'da' })
    popup.retry()
    expect(popup.state.getSnapshot()).toMatchObject({ status: 'pending', error: null })
    await Promise.resolve()
    expect(popup.state.getSnapshot()).toMatchObject({ status: 'ready', options: OPTIONS, search: 'da' })
    expect(attempts).toBe(2)
  })

  it('retry is a no-op unless the options load failed', async () => {
    const { popup } = await readyPopup()
    popup.retry()
    expect(popup.state.getSnapshot().status).toBe('ready')
    const closed = new PopupSelectController<Ctx>(makeDeps())
    closed.retry()
    expect(closed.state.getSnapshot().open).toBe(false)
  })
})

describe('search / move / highlight over the filtered list', () => {
  it('setSearch rebases the highlight to 0 and ignores closed shells and identical text', async () => {
    const { popup } = await readyPopup()
    popup.move(1)
    expect(popup.state.getSnapshot().active).toBe(1)
    popup.setSearch('s')
    expect(popup.state.getSnapshot()).toMatchObject({ search: 's', active: 0 })
    const before = popup.state.getSnapshot()
    popup.setSearch('s')
    expect(popup.state.getSnapshot()).toBe(before)
    const closed = new PopupSelectController<Ctx>(makeDeps())
    closed.setSearch('x')
    expect(closed.state.getSnapshot().search).toBe('')
  })

  it('move wraps across the FILTERED rows', async () => {
    const { popup } = await readyPopup()
    popup.setSearch('a') // Dark, Sepia (detail 'warm' also matches 'a'? label match: Dark, Sepia)
    const rows = filterOptions(popup.state.getSnapshot().options, 'a')
    expect(rows.length).toBe(2)
    popup.move(1)
    expect(popup.state.getSnapshot().active).toBe(1)
    popup.move(1)
    expect(popup.state.getSnapshot().active).toBe(0)
    popup.move(-1)
    expect(popup.state.getSnapshot().active).toBe(1)
  })

  it('move is a no-op while pending, closed, or when the filter matches nothing', async () => {
    const pending = new PopupSelectController<Ctx>(makeDeps())
    pending.open('theme', spec({ options: () => new Promise(() => {}) }), CTX_A, SEGMENT)
    pending.move(1)
    expect(pending.state.getSnapshot().active).toBe(0)
    const closed = new PopupSelectController<Ctx>(makeDeps())
    closed.move(1)
    expect(closed.state.getSnapshot().active).toBe(0)
    const { popup } = await readyPopup()
    popup.setSearch('nope')
    popup.move(1)
    expect(popup.state.getSnapshot().active).toBe(0)
  })

  it('highlight sets the active filtered row and ignores out-of-range or same-index calls', async () => {
    const { popup } = await readyPopup()
    popup.highlight(1)
    expect(popup.state.getSnapshot().active).toBe(1)
    popup.highlight(99)
    popup.highlight(-1)
    popup.highlight(1)
    expect(popup.state.getSnapshot().active).toBe(1)
    popup.setSearch('dark') // one filtered row → index 1 now out of range
    popup.highlight(1)
    expect(popup.state.getSnapshot().active).toBe(0)
  })
})

describe('select', () => {
  it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => {
    const seen: Array<{ option: SelectOption; context: Ctx }> = []
    const deps = makeDeps()
    const { popup } = await readyPopup({
      onSelect: (option, context) => { seen.push({ option, context }) },
    }, deps)
    popup.setSearch('light')
    await popup.select(0)
    expect(seen).toEqual([{ option: OPTIONS[1], context: CTX_A }])
    expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
    expect(deps.focusComposer).toHaveBeenCalledTimes(1)
    expect(popup.state.getSnapshot().open).toBe(false)
  })

  it('is single-flight: the first call enters submitting, later Enter/click calls no-op', async () => {
    let release!: () => void
    const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
    const deps = makeDeps()
    const { popup } = await readyPopup({ onSelect }, deps)
    const first = popup.select(0)
    expect(popup.state.getSnapshot().submitting).toBe(true)
    await popup.select(0)
    await popup.select(1)
    popup.setSearch('x') // locked while submitting
    popup.move(1)
    popup.highlight(1)
    expect(popup.state.getSnapshot()).toMatchObject({ search: '', active: 0 })
    release()
    await first
    expect(onSelect).toHaveBeenCalledTimes(1)
    expect(deps.consume).toHaveBeenCalledTimes(1)
    expect(popup.state.getSnapshot().open).toBe(false)
  })

  it('a consume CAS miss is benign: no retry, still closes and refocuses', async () => {
    const deps = makeDeps(false)
    const { popup } = await readyPopup({}, deps)
    await popup.select(0)
    expect(deps.consume).toHaveBeenCalledTimes(1)
    expect(deps.focusComposer).toHaveBeenCalledTimes(1)
    expect(popup.state.getSnapshot().open).toBe(false)
  })

  it('an onSelect failure keeps the shell open with search/highlight/token intact, no consumption, and select re-arms', async () => {
    let attempts = 0
    const deps = makeDeps()
    const { popup } = await readyPopup({
      onSelect: () => {
        attempts += 1
        if (attempts === 1) throw new Error('host rejected')
        return undefined
      },
    }, deps)
    popup.setSearch('a')
    popup.move(1)
    await popup.select(1)
    expect(popup.state.getSnapshot()).toMatchObject({
      open: true, status: 'ready', submitting: false, error: 'host rejected', search: 'a', active: 1,
    })
    expect(deps.consume).not.toHaveBeenCalled()
    await popup.select(1) // retry = selecting again
    expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
    expect(popup.state.getSnapshot().open).toBe(false)
  })

  it('ignores selects while closed, pending, failed, or out of filtered range', async () => {
    const closed = new PopupSelectController<Ctx>(makeDeps())
    await closed.select(0)
    expect(closed.state.getSnapshot().open).toBe(false)
    const failedDeps = makeDeps()
    const { popup: failed } = await readyPopup({ options: () => Promise.reject(new Error('x')) }, failedDeps)
    await failed.select(0)
    expect(failedDeps.consume).not.toHaveBeenCalled()
    const deps = makeDeps()
    const { popup } = await readyPopup({}, deps)
    popup.setSearch('dark')
    await popup.select(1) // only one filtered row
    expect(deps.consume).not.toHaveBeenCalled()
    expect(popup.state.getSnapshot().open).toBe(true)
  })

  it('a dismiss racing a succeeding onSelect revokes it: no consume, no focus, state stays closed', async () => {
    let release!: () => void
    const deps = makeDeps()
    const { popup } = await readyPopup({
      onSelect: () => new Promise<void>((resolve) => { release = resolve }),
    }, deps)
    const selecting = popup.select(0)
    popup.dismiss()
    release()
    await selecting
    expect(deps.consume).not.toHaveBeenCalled()
    expect(deps.focusComposer).not.toHaveBeenCalled()
    expect(popup.state.getSnapshot().open).toBe(false)
  })

  it('a dispose racing a failing onSelect revokes its error write', async () => {
    let reject!: (error: Error) => void
    const deps = makeDeps()
    const { popup } = await readyPopup({
      onSelect: () => new Promise<void>((_resolve, rej) => { reject = rej }),
    }, deps)
    const selecting = popup.select(0)
    popup.dispose()
    reject(new Error('late'))
    await selecting
    expect(popup.state.getSnapshot()).toMatchObject({ open: false, error: null })
    expect(deps.consume).not.toHaveBeenCalled()
  })

  it('a reopen racing a succeeding onSelect keeps the new shell: no consume of the old segment', async () => {
    let release!: () => void
    const deps = makeDeps()
    const { popup } = await readyPopup({
      onSelect: () => new Promise<void>((resolve) => { release = resolve }),
    }, deps)
    const selecting = popup.select(0)
    popup.open('other', spec(), CTX_A, { via: 'enter', token: '/other' })
    release()
    await selecting
    await Promise.resolve()
    expect(deps.consume).not.toHaveBeenCalled()
    expect(popup.state.getSnapshot()).toMatchObject({ open: true, command: 'other' })
  })
})

describe('dismiss / dispose', () => {
  it('dismiss closes, aborts the flying fetch, and is a no-op when already closed', async () => {
    const deps = makeDeps()
    const popup = new PopupSelectController<Ctx>(deps)
    let signal!: AbortSignal
    popup.open('theme', spec({
      options: (_ctx, s) => {
        signal = s
        return new Promise(() => {})
      },
    }), CTX_A, SEGMENT)
    popup.dismiss()
    expect(signal.aborted).toBe(true)
    expect(popup.state.getSnapshot().open).toBe(false)
    expect(deps.focusComposer).not.toHaveBeenCalled() // outside-pointer path: the click's target takes focus
    popup.dismiss()
    popup.dispose()
    expect(popup.state.getSnapshot().open).toBe(false)
  })

  it('the Escape path restores composer focus explicitly', async () => {
    const deps = makeDeps()
    const { popup } = await readyPopup({}, deps)
    popup.dismiss({ focusComposer: true })
    expect(deps.focusComposer).toHaveBeenCalledTimes(1)
    expect(popup.state.getSnapshot().open).toBe(false)
  })
})
