import type { EntityMatch } from '@lexical/text'
import type { Klass, LexicalEditor, TextNode } from 'lexical'
import { render, renderHook, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND } from 'lexical'
import {
  useBasicTypeaheadTriggerMatch,
  useLexicalTextEntity,
  useSelectOrDelete,
  useTrigger,
} from '../hooks'
import {
  DELETE_CONTEXT_BLOCK_COMMAND,
} from '../plugins/context-block'
import { ContextBlockNode } from '../plugins/context-block/node'
import { DELETE_HISTORY_BLOCK_COMMAND } from '../plugins/history-block'
import { HistoryBlockNode } from '../plugins/history-block/node'
import { DELETE_QUERY_BLOCK_COMMAND } from '../plugins/query-block'
import { QueryBlockNode } from '../plugins/query-block/node'

type MockNode = {
  isDecorator?: boolean
  remove?: () => void
}

type MockSelection = {
  getNodes: () => MockNode[]
  isNodeSelection?: boolean
}

type SelectOrDeleteCommand = Parameters<typeof useSelectOrDelete>[1]
type LexicalTextEntityGetMatch = (text: string) => null | EntityMatch
type LexicalTextEntityCreateNode = (textNode: TextNode) => TextNode

const mockState = vi.hoisted(() => {
  const commandHandlers = new Map<unknown, (event: KeyboardEvent) => boolean>()
  const registerCommand = vi.fn((command: unknown, handler: (event: KeyboardEvent) => boolean) => {
    commandHandlers.set(command, handler)
    return vi.fn()
  })

  return {
    editor: {
      registerCommand,
      registerNodeTransform: vi.fn(),
      dispatchCommand: vi.fn(),
    },
    commandHandlers,
    isSelected: false,
    setSelected: vi.fn(),
    clearSelection: vi.fn(),
    selection: null as MockSelection | null,
    node: null as MockNode | null,
    mergeRegister: vi.fn((...cleanups: Array<() => void>) => {
      return () => {
        cleanups.forEach(cleanup => cleanup())
      }
    }),
    removePlainTextTransform: vi.fn(),
    removeReverseNodeTransform: vi.fn(),
  }
})

vi.mock('@lexical/react/LexicalComposerContext', () => ({
  useLexicalComposerContext: () => [mockState.editor],
}))

vi.mock('@lexical/react/useLexicalNodeSelection', () => ({
  useLexicalNodeSelection: () => [
    mockState.isSelected,
    mockState.setSelected,
    mockState.clearSelection,
  ],
}))

vi.mock('@lexical/utils', () => ({
  mergeRegister: mockState.mergeRegister,
}))

vi.mock('lexical', async (importOriginal) => {
  const actual = await importOriginal<typeof import('lexical')>()
  return {
    ...actual,
    $getSelection: () => mockState.selection,
    $getNodeByKey: () => mockState.node,
    $isDecoratorNode: (node: MockNode | null) => !!node?.isDecorator,
    $isNodeSelection: (selection: MockSelection | null) => !!selection?.isNodeSelection,
  }
})

const SelectOrDeleteHarness = ({ nodeKey, command }: {
  nodeKey: string
  command?: SelectOrDeleteCommand
}) => {
  const [ref, isSelected] = useSelectOrDelete(nodeKey, command)
  return (
    <div
      ref={ref}
      data-testid="select-or-delete-node"
      data-selected={isSelected ? 'true' : 'false'}
    >
      node
    </div>
  )
}

const SelectOrDeleteNoRefHarness = ({ nodeKey, command }: {
  nodeKey: string
  command?: SelectOrDeleteCommand
}) => {
  useSelectOrDelete(nodeKey, command)
  return <div data-testid="select-or-delete-no-ref">node</div>
}

const TriggerHarness = () => {
  const [ref, open] = useTrigger()
  return (
    <div>
      <div ref={ref} data-testid="trigger-target">toggle</div>
      <span>{open ? 'open' : 'closed'}</span>
    </div>
  )
}

const TriggerNoRefHarness = () => {
  const [, open] = useTrigger()
  return <span data-testid="trigger-no-ref-state">{open ? 'open' : 'closed'}</span>
}

const LexicalTextEntityHarness = ({
  getMatch,
  targetNode,
  createNode,
}: {
  getMatch: LexicalTextEntityGetMatch
  targetNode: Klass<TextNode>
  createNode: LexicalTextEntityCreateNode
}) => {
  useLexicalTextEntity(getMatch, targetNode, createNode)
  return null
}

describe('prompt-editor/hooks', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    mockState.commandHandlers.clear()
    mockState.isSelected = false
    mockState.selection = null
    mockState.node = null
    mockState.editor.registerNodeTransform
      .mockReset()
      .mockReturnValueOnce(mockState.removePlainTextTransform)
      .mockReturnValueOnce(mockState.removeReverseNodeTransform)
  })

  // Selection/deletion hook behavior around Lexical node commands.
  describe('useSelectOrDelete', () => {
    it('should register delete and backspace commands and select node on click', async () => {
      const user = userEvent.setup()
      render(
        <SelectOrDeleteHarness
          nodeKey="node-1"
          command={DELETE_CONTEXT_BLOCK_COMMAND}
        />,
      )

      expect(mockState.editor.registerCommand).toHaveBeenCalledWith(
        KEY_DELETE_COMMAND,
        expect.any(Function),
        COMMAND_PRIORITY_LOW,
      )
      expect(mockState.editor.registerCommand).toHaveBeenCalledWith(
        KEY_BACKSPACE_COMMAND,
        expect.any(Function),
        COMMAND_PRIORITY_LOW,
      )

      await user.click(screen.getByTestId('select-or-delete-node'))

      expect(mockState.clearSelection).toHaveBeenCalled()
      expect(mockState.setSelected).toHaveBeenCalledWith(true)
    })

    it('should dispatch delete command when unselected context block is focused', () => {
      mockState.isSelected = false
      mockState.selection = {
        getNodes: () => [Object.create(ContextBlockNode.prototype) as MockNode],
        isNodeSelection: false,
      }

      render(
        <SelectOrDeleteHarness
          nodeKey="node-1"
          command={DELETE_CONTEXT_BLOCK_COMMAND}
        />,
      )

      const deleteHandler = mockState.commandHandlers.get(KEY_DELETE_COMMAND)
      expect(deleteHandler).toBeDefined()

      const handled = deleteHandler?.(new KeyboardEvent('keydown'))

      expect(handled).toBe(false)
      expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith(DELETE_CONTEXT_BLOCK_COMMAND, undefined)
    })

    it('should dispatch delete command when unselected history block is focused', () => {
      mockState.isSelected = false
      mockState.selection = {
        getNodes: () => [Object.create(HistoryBlockNode.prototype) as MockNode],
        isNodeSelection: false,
      }

      render(
        <SelectOrDeleteHarness
          nodeKey="node-1"
          command={DELETE_HISTORY_BLOCK_COMMAND}
        />,
      )

      const deleteHandler = mockState.commandHandlers.get(KEY_DELETE_COMMAND)
      const handled = deleteHandler?.(new KeyboardEvent('keydown'))

      expect(handled).toBe(false)
      expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith(DELETE_HISTORY_BLOCK_COMMAND, undefined)
    })

    it('should dispatch delete command when unselected query block is focused', () => {
      mockState.isSelected = false
      mockState.selection = {
        getNodes: () => [Object.create(QueryBlockNode.prototype) as MockNode],
        isNodeSelection: false,
      }

      render(
        <SelectOrDeleteHarness
          nodeKey="node-1"
          command={DELETE_QUERY_BLOCK_COMMAND}
        />,
      )

      const deleteHandler = mockState.commandHandlers.get(KEY_DELETE_COMMAND)
      const handled = deleteHandler?.(new KeyboardEvent('keydown'))

      expect(handled).toBe(false)
      expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith(DELETE_QUERY_BLOCK_COMMAND, undefined)
    })

    it('should prevent default and remove selected decorator node on delete', () => {
      const remove = vi.fn()
      const preventDefault = vi.fn()
      mockState.isSelected = true
      mockState.selection = {
        getNodes: () => [Object.create(QueryBlockNode.prototype) as MockNode],
        isNodeSelection: true,
      }
      mockState.node = {
        isDecorator: true,
        remove,
      }

      render(
        <SelectOrDeleteHarness
          nodeKey="node-1"
          command={DELETE_QUERY_BLOCK_COMMAND}
        />,
      )

      const backspaceHandler = mockState.commandHandlers.get(KEY_BACKSPACE_COMMAND)
      expect(backspaceHandler).toBeDefined()

      const handled = backspaceHandler?.({ preventDefault } as unknown as KeyboardEvent)

      expect(handled).toBe(true)
      expect(preventDefault).toHaveBeenCalled()
      expect(mockState.editor.dispatchCommand).toHaveBeenCalledWith(DELETE_QUERY_BLOCK_COMMAND, undefined)
      expect(remove).toHaveBeenCalled()
    })

    it('should remove selected decorator node without dispatching when command is undefined', () => {
      const remove = vi.fn()
      const preventDefault = vi.fn()
      mockState.isSelected = true
      mockState.selection = {
        getNodes: () => [Object.create(QueryBlockNode.prototype) as MockNode],
        isNodeSelection: true,
      }
      mockState.node = { isDecorator: true, remove }

      render(<SelectOrDeleteHarness nodeKey="node-1" />)

      const deleteHandler = mockState.commandHandlers.get(KEY_DELETE_COMMAND)
      const handled = deleteHandler?.({ preventDefault } as unknown as KeyboardEvent)

      expect(handled).toBe(true)
      expect(remove).toHaveBeenCalled()
      expect(mockState.editor.dispatchCommand).not.toHaveBeenCalled()
    })

    it('should return false when selected node is not a decorator node', () => {
      const preventDefault = vi.fn()
      mockState.isSelected = true
      mockState.selection = {
        getNodes: () => [Object.create(QueryBlockNode.prototype) as MockNode],
        isNodeSelection: true,
      }
      mockState.node = { isDecorator: false, remove: vi.fn() }

      render(
        <SelectOrDeleteHarness nodeKey="node-1" command={DELETE_QUERY_BLOCK_COMMAND} />,
      )

      const deleteHandler = mockState.commandHandlers.get(KEY_DELETE_COMMAND)
      const handled = deleteHandler?.({ preventDefault } as unknown as KeyboardEvent)
      expect(handled).toBe(false)
    })

    it('should not select when metaKey is pressed on click', () => {
      render(
        <SelectOrDeleteHarness nodeKey="node-1" command={DELETE_CONTEXT_BLOCK_COMMAND} />,
      )

      const node = screen.getByTestId('select-or-delete-node')
      node.dispatchEvent(new MouseEvent('click', { bubbles: true, metaKey: true }))

      expect(mockState.clearSelection).not.toHaveBeenCalled()
      expect(mockState.setSelected).not.toHaveBeenCalled()
    })

    it('should not select when ctrlKey is pressed on click', () => {
      render(
        <SelectOrDeleteHarness nodeKey="node-1" command={DELETE_CONTEXT_BLOCK_COMMAND} />,
      )

      const node = screen.getByTestId('select-or-delete-node')
      node.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true }))

      expect(mockState.clearSelection).not.toHaveBeenCalled()
      expect(mockState.setSelected).not.toHaveBeenCalled()
    })

    it('should skip select listener registration when consumer does not attach the returned ref', () => {
      const { unmount } = render(
        <SelectOrDeleteNoRefHarness nodeKey="node-1" command={DELETE_CONTEXT_BLOCK_COMMAND} />,
      )

      screen.getByTestId('select-or-delete-no-ref').dispatchEvent(new MouseEvent('click', { bubbles: true }))

      expect(mockState.clearSelection).not.toHaveBeenCalled()
      expect(mockState.setSelected).not.toHaveBeenCalled()

      expect(() => unmount()).not.toThrow()
    })
  })

  // Trigger hook toggles dropdown/popup state from bound DOM element.
  describe('useTrigger', () => {
    it('should toggle open state when trigger element is clicked', async () => {
      const user = userEvent.setup()
      render(<TriggerHarness />)

      expect(screen.getByText('closed'))!.toBeInTheDocument()

      await user.click(screen.getByTestId('trigger-target'))
      expect(screen.getByText('open'))!.toBeInTheDocument()

      await user.click(screen.getByTestId('trigger-target'))
      expect(screen.getByText('closed'))!.toBeInTheDocument()
    })

    it('should keep state unchanged when consumer does not attach the returned ref', async () => {
      const user = userEvent.setup()
      const { unmount } = render(<TriggerNoRefHarness />)

      expect(screen.getByTestId('trigger-no-ref-state'))!.toHaveTextContent('closed')

      await user.click(screen.getByTestId('trigger-no-ref-state'))
      expect(screen.getByTestId('trigger-no-ref-state'))!.toHaveTextContent('closed')

      expect(() => unmount()).not.toThrow()
    })
  })

  // Lexical entity hook should register and cleanup transforms.
  describe('useLexicalTextEntity', () => {
    it('should register lexical text entity transforms and cleanup on unmount', () => {
      class MockTargetNode { }
      const getMatch: LexicalTextEntityGetMatch = vi.fn(() => null)
      const createNode: LexicalTextEntityCreateNode = vi.fn((textNode: TextNode) => textNode)

      const { unmount } = render(
        <LexicalTextEntityHarness
          getMatch={getMatch}
          targetNode={MockTargetNode as unknown as Klass<TextNode>}
          createNode={createNode}
        />,
      )

      expect(mockState.editor.registerNodeTransform).toHaveBeenCalledTimes(2)
      // Verify the first call uses TextNode, not MockTargetNode
      const calls = mockState.editor.registerNodeTransform.mock.calls
      expect(calls[0]![0]).not.toBe(MockTargetNode)
      expect(typeof calls[0]![0]).toBe('function')
      expect(mockState.editor.registerNodeTransform).toHaveBeenCalledWith(
        MockTargetNode,
        expect.any(Function),
      )

      unmount()

      expect(getMatch).not.toHaveBeenCalled()
      expect(createNode).not.toHaveBeenCalled()
      expect(mockState.removePlainTextTransform).toHaveBeenCalled()
      expect(mockState.removeReverseNodeTransform).toHaveBeenCalled()
    })
  })

  // Regex trigger matcher behavior for typeahead text detection.
  describe('useBasicTypeaheadTriggerMatch', () => {
    it('should return match details when input satisfies trigger and length rules', () => {
      const { result } = renderHook(() => useBasicTypeaheadTriggerMatch('@', {
        minLength: 2,
        maxLength: 5,
      }))

      const match = result.current('prefix @ab', {} as LexicalEditor)
      expect(match).toEqual({
        leadOffset: 7,
        matchingString: 'ab',
        replaceableString: '@ab',
      })
    })

    it('should return null when matching text is shorter than minLength', () => {
      const { result } = renderHook(() => useBasicTypeaheadTriggerMatch('@', {
        minLength: 2,
        maxLength: 5,
      }))

      expect(result.current('prefix @a', {} as LexicalEditor)).toBeNull()
    })

    it('should return null when matching text exceeds maxLength', () => {
      const { result } = renderHook(() => useBasicTypeaheadTriggerMatch('@', {
        minLength: 1,
        maxLength: 2,
      }))
      expect(result.current('prefix @abc', {} as LexicalEditor)).toBeNull()
    })

    it('should return null when text has no trigger character', () => {
      const { result } = renderHook(() => useBasicTypeaheadTriggerMatch('@', {
        minLength: 1,
        maxLength: 75,
      }))
      expect(result.current('no trigger here', {} as LexicalEditor)).toBeNull()
    })
  })
})
