import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { translateBots } from './i18n-test-helper'
import type { GroupMember } from './types'

// Group-composer mentions (#89049): the core composer's @-completion area
// doesn't mount inside workspace tiles, so the room's composers wrap the SDK
// input with a member-scoped popover of their own. Everything it inserts has
// to be a string parseGroupChatMentions resolves.

const { host } = vi.hoisted(() => ({ host: {} as Record<string, unknown> }))

vi.mock('@hermes/plugin-sdk', async () => {
  const { pluginSdkMock } = await import('./group-test-utils')
  const base = await pluginSdkMock(host)

  return {
    ...base,
    Button: (props: React.ComponentProps<'button'>) => <button type="button" {...props} />,
    cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
    Codicon: () => null,
    Input: (props: React.ComponentProps<'input'>) => <input {...props} />,
    RowButton: (props: React.ComponentProps<'button'>) => <button type="button" {...props} />,
    Textarea: (props: React.ComponentProps<'textarea'>) => <textarea {...props} />,
    useI18n: () => ({ t: (_key: string, fallback: string) => fallback }),
    // The plugin bundle normally lands via `ctx.i18n.register` at load, so
    // without this every localized label renders empty.
    usePluginI18n: () => translateBots
  }
})

const MEMBERS: GroupMember[] = [
  { handle: 'alpha', name: 'alpha', title: '' },
  { handle: 'builder', name: 'builder', title: '' }
]

/** Render the composer the way a room does: value owned by the caller (the
 *  draft atom in production), popover scoped to the seated members. */
async function mount(initial = '') {
  const { GroupMentionInput } = await import('./group-chat-parts')
  const onChange = vi.fn()
  const onSubmitDraft = vi.fn()

  function Harness() {
    const [value, setValue] = useState(initial)

    return (
      <GroupMentionInput
        aria-label="Message Core"
        members={MEMBERS}
        onChange={next => {
          onChange(next)
          setValue(next)
        }}
        onSubmitDraft={onSubmitDraft}
        value={value}
      />
    )
  }

  render(<Harness />)

  return { input: screen.getByLabelText('Message Core') as HTMLTextAreaElement, onChange, onSubmitDraft }
}

/** Type `text`, then park the caret at `caret` (default: end of the text).
 *  The click is how the component re-reads the caret without a keystroke —
 *  jsdom does not preserve a selection across React's controlled re-write. */
function typeInto(input: HTMLTextAreaElement, text: string, caret = text.length) {
  fireEvent.change(input, { target: { value: text } })
  input.setSelectionRange(caret, caret)
  fireEvent.click(input)
}

const options = () => screen.queryAllByRole('button').map(button => button.textContent || '')

beforeEach(() => {
  vi.resetModules()
})

afterEach(() => {
  cleanup()
  vi.clearAllMocks()
})

describe('the @-token at the caret', () => {
  it('opens the popover on a token that begins a word', async () => {
    const { input } = await mount()

    typeInto(input, 'hey @al')

    expect(options().some(label => label.startsWith('@alpha'))).toBe(true)
  })

  it('offers @everyone and @all as the room-wide broadcast quick picks', async () => {
    const { input } = await mount()

    typeInto(input, '@')

    expect(options().some(label => label.startsWith('@everyone'))).toBe(true)
    expect(options().some(label => label.startsWith('@all'))).toBe(true)
  })

  it('narrows to @everyone as the query grows', async () => {
    const { input } = await mount()

    typeInto(input, 'ping @every')

    expect(options().filter(label => label.startsWith('@everyone'))).toHaveLength(1)
    expect(options().some(label => label.startsWith('@alpha'))).toBe(false)
  })

  it('stays closed mid-word, on plain text, and with the caret before the @', async () => {
    const { input } = await mount()

    typeInto(input, 'email me a@b')

    expect(options()).toHaveLength(0)

    typeInto(input, 'plain text')

    expect(options()).toHaveLength(0)

    typeInto(input, 'hey @al', 3)

    expect(options()).toHaveLength(0)
  })
})

describe('insertion', () => {
  it('writes exactly "@handle " — the shape parseGroupChatMentions resolves', async () => {
    const { input, onChange } = await mount()

    typeInto(input, 'hey @al')
    const option = screen.getAllByRole('button').find(button => button.textContent?.startsWith('@alpha'))
    fireEvent.mouseDown(option!)

    expect(onChange).toHaveBeenLastCalledWith('hey @alpha ')
  })

  it('preventDefaults the mousedown so the input keeps focus', async () => {
    const { input } = await mount()

    typeInto(input, 'hey @al')
    const option = screen.getAllByRole('button').find(button => button.textContent?.startsWith('@alpha'))

    // fireEvent returns false when a handler called preventDefault.
    expect(fireEvent.mouseDown(option!)).toBe(false)
  })

  it('replaces the whole token, not just the typed suffix', async () => {
    const { input, onChange } = await mount()

    typeInto(input, '@bui and then some', 4)
    const option = screen.getAllByRole('button').find(button => button.textContent?.startsWith('@builder'))
    fireEvent.mouseDown(option!)

    expect(onChange).toHaveBeenLastCalledWith('@builder  and then some')
  })

  it('lists every seated member under a bare @, keyed by handle', async () => {
    const { input } = await mount()

    typeInto(input, '@')

    // Handles only — a profile name that never resolved would surface as
    // "@undefined" and route to nobody.
    expect(options().some(label => label.startsWith('@alpha'))).toBe(true)
    expect(options().some(label => label.startsWith('@builder'))).toBe(true)
    expect(options().some(label => label.startsWith('@undefined'))).toBe(false)
  })
})

// #89884: the composer used to be a single-line Input whose form submitted on
// every Enter, so multi-line room prompts were impossible.
describe('keyboard (#89884)', () => {
  it('submits on Enter and leaves Shift+Enter to the textarea', async () => {
    const { input, onSubmitDraft } = await mount('a room prompt')

    fireEvent.keyDown(input, { key: 'Enter' })

    expect(onSubmitDraft).toHaveBeenCalledTimes(1)

    fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })

    expect(onSubmitDraft).toHaveBeenCalledTimes(1)
  })

  it('inserts the highlighted mention on Enter while the popover is open', async () => {
    const { input, onChange, onSubmitDraft } = await mount()

    typeInto(input, 'hey @alp')
    fireEvent.keyDown(input, { key: 'Enter' })

    expect(onChange).toHaveBeenLastCalledWith('hey @alpha ')
    expect(onSubmitDraft).not.toHaveBeenCalled()
  })

  // #93528: Enter here confirms composed Chinese/Japanese/Korean text. It must
  // neither insert a mention nor submit the draft. isComposing covers Chromium;
  // keyCode 229 covers macOS Chinese IMEs that fire Enter after compositionend
  // with isComposing already false.
  it('swallows IME composition Enters (#93528)', async () => {
    const { input, onSubmitDraft } = await mount('中文')

    fireEvent.keyDown(input, { isComposing: true, key: 'Enter' })
    fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 })

    expect(onSubmitDraft).not.toHaveBeenCalled()
  })
})
