import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { fireEvent, renderWithProviders, screen } from '../../test/utils'

import { CustomMessageInput } from '.'

let mockChannelData: Record<string, unknown> = {}

// Controls whether the composer treats the input as sendable, which in turn
// enables/disables the send button. Defaults to false to match the existing
// suite; individual tests flip it to true when they need an enabled button.
let mockHasSendableData = false

// Stable submit spy so a test can assert the send button still fires it on
// click (a new vi.fn() per render would be unassertable).
const mockHandleSubmit = vi.fn()

let mockContextSendButton:
  | React.ComponentType<{
      sendMessage: (...args: unknown[]) => unknown
      disabled?: boolean
    }>
  | undefined = ({ sendMessage, disabled }) => (
  <button
    type="button"
    data-testid="send-button"
    aria-label="Send"
    disabled={disabled}
    onClick={() => sendMessage()}
  />
)

vi.mock('stream-chat-react', () => ({
  MessageInput: ({ Input }: { Input: React.ComponentType }) => (
    <div data-testid="stream-message-input">
      <Input />
    </div>
  ),
  // `maxRows` is surfaced as a data attribute rather than spread onto the
  // <textarea> (which React would warn about) so a test can still assert it —
  // stripping it silently would hide the clamp that MES-1037 removed.
  TextareaComposer: ({
    maxRows,
    ...props
  }: React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
    maxRows?: number
  }) => (
    <textarea
      data-testid="textarea-composer"
      data-max-rows={String(maxRows)}
      {...props}
    />
  ),
  AttachmentPreviewList: () => <div data-testid="attachment-preview-list" />,
  QuotedMessagePreview: () => <div data-testid="quoted-message-preview" />,
  useChannelStateContext: () => ({
    channel: { data: mockChannelData },
  }),
  useMessageInputContext: () => ({
    handleSubmit: mockHandleSubmit,
  }),
  useMessageComposerHasSendableData: () => mockHasSendableData,
  useComponentContext: () => ({
    SendButton: mockContextSendButton,
  }),
}))

vi.mock('../CustomLinkPreviewList', () => ({
  CustomLinkPreviewList: () => <div data-testid="custom-link-preview-list" />,
}))

describe('CustomMessageInput', () => {
  beforeEach(() => {
    mockHasSendableData = false
    mockHandleSubmit.mockClear()
    mockContextSendButton = ({ sendMessage, disabled }) => (
      <button
        type="button"
        data-testid="send-button"
        aria-label="Send"
        disabled={disabled}
        onClick={() => sendMessage()}
      />
    )
  })

  it('renders the interactive message input when channel is not frozen', () => {
    mockChannelData = {}

    const { container } = renderWithProviders(<CustomMessageInput />)

    const messageInput = container.firstElementChild
    expect(messageInput).not.toHaveAttribute('aria-disabled')
    expect(messageInput).not.toHaveAttribute('inert')
    expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
  })

  it('renders the frozen message input when channel is frozen', () => {
    mockChannelData = { frozen: true }

    const { container } = renderWithProviders(<CustomMessageInput />)

    const messageInput = container.querySelector('.message-input')
    expect(messageInput).toHaveAttribute('aria-disabled', 'true')
    expect(messageInput).toHaveAttribute('inert')
    expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
  })

  it('makes the textarea read-only and disables send when channel is frozen', () => {
    mockChannelData = { frozen: true }

    renderWithProviders(<CustomMessageInput />)

    const textarea = screen.getByTestId('textarea-composer')
    expect(textarea).toHaveAttribute('aria-disabled', 'true')
    expect(textarea).toHaveAttribute('readonly')
    expect(textarea).toHaveAttribute('tabindex', '-1')
    expect(textarea).not.toHaveAttribute('autofocus')

    const sendButton = screen.getByRole('button', { name: /send/i })
    expect(sendButton).toBeDisabled()
  })

  it('renders adjacent actions inside the disabled container when frozen', () => {
    mockChannelData = { frozen: true }

    const { container } = renderWithProviders(
      <CustomMessageInput
        renderActions={() => (
          <button data-testid="custom-action" type="button">
            Action
          </button>
        )}
      />
    )

    const messageInput = container.querySelector('.message-input')
    const action = screen.getByTestId('custom-action')

    expect(messageInput).toHaveAttribute('aria-disabled', 'true')
    expect(messageInput?.contains(action)).toBe(true)
  })

  it('renders adjacent actions in the interactive container when not frozen', () => {
    mockChannelData = {}

    const { container } = renderWithProviders(
      <CustomMessageInput
        renderActions={() => (
          <button data-testid="custom-action" type="button">
            Action
          </button>
        )}
      />
    )

    const messageInput = container.firstElementChild
    expect(messageInput).not.toHaveAttribute('aria-disabled')
    expect(screen.getByTestId('custom-action')).toBeInTheDocument()
  })

  it('renders the custom SendButton from component context', () => {
    mockChannelData = {}
    mockContextSendButton = () => (
      <button
        type="button"
        data-testid="custom-context-send-button"
        aria-label="Custom Send"
      />
    )

    renderWithProviders(<CustomMessageInput />)

    expect(screen.getByTestId('custom-context-send-button')).toBeInTheDocument()
    expect(screen.queryByTestId('default-send-button')).not.toBeInTheDocument()
  })

  it('falls back to the arrow send button when component context provides none', () => {
    mockChannelData = {}
    mockContextSendButton = undefined

    renderWithProviders(<CustomMessageInput />)

    expect(screen.getByTestId('send-button')).toBeInTheDocument()
    expect(
      screen.queryByTestId('custom-context-send-button')
    ).not.toBeInTheDocument()
  })

  // MES-1320: tapping the default send button must not blur the composer, or
  // the mobile soft keyboard closes after every sent message. The button
  // prevents the default pointerdown focus shift while leaving the click (and
  // therefore the send) intact.
  describe('keeps the composer focused when tapping the default send button (MES-1320)', () => {
    it('prevents the default pointerdown so focus stays in the textarea', () => {
      mockChannelData = {}
      mockContextSendButton = undefined
      mockHasSendableData = true

      renderWithProviders(<CustomMessageInput />)

      const sendButton = screen.getByTestId('send-button')
      // fireEvent returns false when the dispatched event was cancelled
      // (preventDefault was called on a cancelable event).
      const notCancelled = fireEvent.pointerDown(sendButton)
      expect(notCancelled).toBe(false)
    })

    it('still submits on click', () => {
      mockChannelData = {}
      mockContextSendButton = undefined
      mockHasSendableData = true

      renderWithProviders(<CustomMessageInput />)

      const sendButton = screen.getByTestId('send-button')
      fireEvent.click(sendButton)
      expect(mockHandleSubmit).toHaveBeenCalledTimes(1)
    })
  })

  it('preserves disabled state on the send button when there is no sendable data', () => {
    mockChannelData = {}

    renderWithProviders(<CustomMessageInput />)

    const sendButton = screen.getByRole('button', { name: /send/i })
    // useMessageComposerHasSendableData is mocked to return false
    expect(sendButton).toBeDisabled()
  })

  it('replaces the composer with the locked panel when disabled (channel not frozen)', () => {
    mockChannelData = {}

    const { container } = renderWithProviders(
      <CustomMessageInput
        disabled
        disabledReason="Only Linktree can send messages on this thread"
      />
    )

    // The interactive input is gone entirely — no textarea, no send button.
    expect(screen.queryByTestId('stream-message-input')).not.toBeInTheDocument()
    expect(screen.queryByTestId('textarea-composer')).not.toBeInTheDocument()
    expect(container.querySelector('.message-input')).not.toBeInTheDocument()

    // The locked panel with the reason replaces it.
    expect(
      container.querySelector('.messaging-composer-locked-panel')
    ).toBeInTheDocument()
    expect(
      screen.getByText(/Only Linktree can send messages on this thread/i)
    ).toBeInTheDocument()
  })

  it('renders a custom composerInput in place of the default composer', () => {
    mockChannelData = {}

    renderWithProviders(
      <CustomMessageInput
        composerInput={() => (
          <div data-testid="custom-composer">custom composer</div>
        )}
      />
    )

    // The host-owned composer renders instead of the built-in textarea/send.
    expect(screen.getByTestId('custom-composer')).toBeInTheDocument()
    expect(screen.queryByTestId('textarea-composer')).not.toBeInTheDocument()
    // It is still mounted inside Stream's <MessageInput> so it keeps the
    // message-input context (send pipeline, attachments, previews).
    expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
  })

  it('renders renderHeader above the floating composer controls', () => {
    mockChannelData = {}

    const { container } = renderWithProviders(
      <CustomMessageInput
        composerInput={() => <div data-testid="custom-composer" />}
        renderHeader={() => <div data-testid="composer-header">header</div>}
      />
    )

    const chrome = container.querySelector(
      '.messaging-composer-chrome--floating'
    ) as HTMLElement | null
    const header = screen.getByTestId('composer-header')
    const messageInput = container.querySelector('.message-input')

    expect(chrome).not.toBeNull()
    expect(chrome).toContainElement(header)
    expect(messageInput).not.toBeNull()
    expect(
      header.compareDocumentPosition(messageInput as Node) &
        Node.DOCUMENT_POSITION_FOLLOWING
    ).toBeTruthy()
  })

  it('keeps the frozen wrapper around a custom composerInput', () => {
    mockChannelData = { frozen: true }

    const { container } = renderWithProviders(
      <CustomMessageInput
        composerInput={() => <div data-testid="custom-composer" />}
      />
    )

    const messageInput = container.querySelector('.message-input')
    expect(messageInput).toHaveAttribute('aria-disabled', 'true')
    expect(messageInput).toHaveAttribute('inert')
    expect(screen.getByTestId('custom-composer')).toBeInTheDocument()
  })

  it('does not render the locked panel when the composer is not disabled', () => {
    mockChannelData = {}

    const { container } = renderWithProviders(
      <CustomMessageInput disabledReason="should not show" />
    )

    expect(
      container.querySelector('.messaging-composer-locked-panel')
    ).not.toBeInTheDocument()
    expect(screen.queryByText(/should not show/i)).not.toBeInTheDocument()
    expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
  })

  // MES-1037: a staged link preview made the composer tall enough for the soft
  // keyboard to cover the textarea and the send button. The card now caps itself
  // against the keyboard-shrunk surface and scrolls as one region.
  //
  // None of the *layout* can be verified here — jsdom has no layout engine, so
  // no sticky, no overflow, and the polyfilled ResizeObserver measures
  // getBoundingClientRect, which jsdom always reports as zero. These are
  // regression guards on the classes and the observer's behaviour; the layout
  // itself was verified in real Chrome over the DevTools protocol.
  describe('keeps the composer clear of the soft keyboard (MES-1037)', () => {
    let resizeCard: (() => void) | undefined

    beforeEach(() => {
      mockChannelData = {}
      resizeCard = undefined
      vi.stubGlobal(
        'ResizeObserver',
        class {
          constructor(cb: ResizeObserverCallback) {
            resizeCard = () => cb([], this as unknown as ResizeObserver)
          }
          observe() {}
          unobserve() {}
          disconnect() {}
        }
      )
    })

    afterEach(() => {
      vi.unstubAllGlobals()
    })

    const renderCard = () => {
      const { container } = renderWithProviders(<CustomMessageInput />)
      return container.querySelector('.central-container') as HTMLElement
    }

    it('caps the card against the surface height and scrolls it as one region', () => {
      const card = renderCard()

      expect(card).toHaveClass(
        'max-h-[calc(var(--messaging-surface-height,100dvh)*0.5)]',
        'overflow-y-auto',
        'overscroll-contain'
      )
      // Nothing inside may shrink, or the previews get cropped to make room for
      // the textarea instead of scrolling with it.
      expect(card).not.toHaveClass('min-h-0')
      expect(
        card.querySelector('.messaging-composer-previews')
      ).not.toHaveClass('min-h-0')
      expect(card.querySelector('.messaging-composer-row')).not.toHaveClass(
        'shrink-0'
      )
    })

    it('lets the textarea grow to fit rather than scrolling its own overflow', () => {
      const card = renderCard()

      // TextareaComposer defaults an absent maxRows to 1, so this has to be set.
      expect(screen.getByTestId('textarea-composer')).toHaveAttribute(
        'data-max-rows',
        'Infinity'
      )
      // Growing to fit is what keeps it from overflowing; overflow-hidden is
      // what keeps it from becoming a second scrollbar on the occasions the
      // autosize is momentarily out of date (a width change it never hears
      // about, e.g. the card gaining its own scrollbar and rewrapping the text).
      expect(screen.getByTestId('textarea-composer')).toHaveClass(
        'overflow-hidden'
      )
      // And the card stays the one region that does scroll.
      expect(card).toHaveClass('overflow-y-auto')
    })

    it('leaves plain-text pastes to the browser but lets file pastes reach Stream', () => {
      renderCard()

      // Stream's paste handler is dispatched from React's root, above this
      // card, so a listener further up stands in for it: the event reaching
      // there means Stream would have handled it (and preventDefault'd the
      // browser's own insert, which is what leaves the composer unresized).
      const reachedStream = vi.fn()
      document.addEventListener('paste', reachedStream)

      const paste = (items: Array<{ kind: string }>) => {
        const event = new Event('paste', { bubbles: true, cancelable: true })
        Object.defineProperty(event, 'clipboardData', { value: { items } })
        screen.getByTestId('textarea-composer').dispatchEvent(event)
      }

      paste([{ kind: 'string' }])
      expect(reachedStream).not.toHaveBeenCalled()

      paste([{ kind: 'file' }])
      expect(reachedStream).toHaveBeenCalledTimes(1)

      document.removeEventListener('paste', reachedStream)
    })

    it('detects a pasted file via clipboardData.items even when .files is empty (Safari)', () => {
      renderCard()

      // Safari never populates clipboardData.files on paste, even for an
      // image — only .items has it. Checking .files here would silently drop
      // a pasted screenshot instead of handing it to Stream to upload.
      const reachedStream = vi.fn()
      document.addEventListener('paste', reachedStream)

      const event = new Event('paste', { bubbles: true, cancelable: true })
      Object.defineProperty(event, 'clipboardData', {
        value: { files: [], items: [{ kind: 'file' }] },
      })
      screen.getByTestId('textarea-composer').dispatchEvent(event)

      expect(reachedStream).toHaveBeenCalledTimes(1)

      document.removeEventListener('paste', reachedStream)
    })

    it('scrolls the input back into view when the card shrinks under it', () => {
      const card = renderCard()
      // jsdom has no layout, so stand in for a card whose cap now cuts the
      // input off below the fold.
      Object.defineProperty(card, 'scrollHeight', {
        configurable: true,
        value: 380,
      })
      screen.getByTestId('textarea-composer').focus()

      resizeCard?.()

      expect(card.scrollTop).toBe(380)
    })

    it('leaves the scroll position alone when nothing in the composer is focused', () => {
      const card = renderCard()
      Object.defineProperty(card, 'scrollHeight', {
        configurable: true,
        value: 380,
      })
      // The composer autofocuses, so blur to reach the case this guards: a
      // resize with no keyboard up must not yank a visitor reading a preview.
      screen.getByTestId('textarea-composer').blur()

      resizeCard?.()

      expect(card.scrollTop).toBe(0)
    })

    it('sticks the send button to the bottom of the scroll, at its resting inset', () => {
      renderCard()

      // The offset must match the wrapper's own margin: sticky resolves against
      // the border box, so at bottom-0 the button would lose that inset and sit
      // flush with the card's edge for as long as it stayed stuck. pt-2 gives
      // the same inset above: sticky offsets only the bottom edge, so once the
      // card scrolls far enough for the button to ride up to the top of the
      // row, a staged preview otherwise meets it flush. Asserted together so
      // they cannot drift apart.
      expect(screen.getByTestId('send-button').parentElement).toHaveClass(
        'sticky',
        'bottom-2',
        'mb-2',
        'pt-2'
      )
    })
  })

  // MES-1303: the floating composer overlays the message list, so growing it
  // reserves space via padding-bottom but never moves the viewport. The resize
  // observer re-pins the thread to the latest message when the composer grows —
  // but only if the user was already at the bottom.
  describe('re-pins the thread when the floating composer grows (MES-1303)', () => {
    // Render the floating composer inside the real layout structure so its
    // `parentElement` is `.messaging-channel-layout` and it can find the Stream
    // scroll container the way it does in ChannelView.
    const renderInLayout = () =>
      renderWithProviders(
        <div className="messaging-channel-layout">
          <div className="messaging-channel-message-list--floating">
            {/* `.str-chat__list` is the real scroll viewport (overflow-y:
                auto); the re-pin targets it, not the padded content wrapper. */}
            <div className="str-chat__list" data-testid="scroller" />
          </div>
          <CustomMessageInput
            composerInput={() => <div data-testid="custom-composer" />}
          />
        </div>
      )

    // Force jsdom's zero-valued scroll geometry to a chosen shape. jsdom has no
    // layout engine, so setProperty can't grow scrollHeight the way a browser
    // reflow would; we set the post-growth geometry directly.
    const setScrollGeometry = (
      el: HTMLElement,
      { scrollHeight, clientHeight, scrollTop }: Record<string, number>
    ) => {
      Object.defineProperty(el, 'scrollHeight', {
        value: scrollHeight,
        configurable: true,
      })
      Object.defineProperty(el, 'clientHeight', {
        value: clientHeight,
        configurable: true,
      })
      el.scrollTop = scrollTop
    }

    // Simulate the composer growing/shrinking. The observer only re-pins when
    // the composer's offsetHeight increased; jsdom reports 0, so set it
    // explicitly. Mounts with height 0, so any positive value reads as growth.
    const setComposerHeight = (container: HTMLElement, height: number) => {
      const composer = container.querySelector(
        '.messaging-composer-chrome--floating'
      ) as HTMLElement
      Object.defineProperty(composer, 'offsetHeight', {
        value: height,
        configurable: true,
      })
    }

    let resizeCallback: ResizeObserverCallback | undefined

    beforeEach(() => {
      mockChannelData = {}
      resizeCallback = undefined
      vi.stubGlobal(
        'ResizeObserver',
        class {
          constructor(cb: ResizeObserverCallback) {
            resizeCallback = cb
          }
          observe() {}
          unobserve() {}
          disconnect() {}
        }
      )
    })

    afterEach(() => {
      vi.unstubAllGlobals()
    })

    it('scrolls to the bottom when the composer grows and the user was at the bottom', () => {
      const { container } = renderInLayout()
      const scroller = screen.getByTestId('scroller')

      // At the bottom: scrollHeight - scrollTop - clientHeight = 0.
      setScrollGeometry(scroller, {
        scrollHeight: 560,
        clientHeight: 300,
        scrollTop: 260,
      })
      setComposerHeight(container, 200) // grew from 0

      resizeCallback?.([], {} as ResizeObserver)

      expect(scroller.scrollTop).toBe(560)
    })

    it('leaves the scroll position alone when the user has scrolled up', () => {
      const { container } = renderInLayout()
      const scroller = screen.getByTestId('scroller')

      // Scrolled up reading history: distance from bottom (600px) is well over
      // the threshold.
      setScrollGeometry(scroller, {
        scrollHeight: 1000,
        clientHeight: 300,
        scrollTop: 100,
      })
      setComposerHeight(container, 200) // grew, but user is not near the bottom

      resizeCallback?.([], {} as ResizeObserver)

      expect(scroller.scrollTop).toBe(100)
    })

    it('does not re-pin when the composer did not grow (shrink / width-only resize)', () => {
      renderInLayout()
      const scroller = screen.getByTestId('scroller')

      // User is at the bottom, but the composer height is unchanged (0 → 0),
      // e.g. a width-only window resize — nothing new covers the last message.
      setScrollGeometry(scroller, {
        scrollHeight: 560,
        clientHeight: 300,
        scrollTop: 260,
      })

      resizeCallback?.([], {} as ResizeObserver)

      expect(scroller.scrollTop).toBe(260)
    })
  })
})
