import { ArrowUpIcon } from '@phosphor-icons/react'
import React, { useContext } from 'react'
import {
  AttachmentPreviewList as DefaultAttachmentPreviewList,
  MessageInput,
  QuotedMessagePreview,
  TextareaComposer,
  useChannelStateContext,
  useComponentContext,
  useMessageComposerHasSendableData,
  useMessageInputContext,
} from 'stream-chat-react'

import { CustomLinkPreviewList } from '../CustomLinkPreviewList'

/**
 * Carries the channel's `frozen` state down to CustomMessageInputInner, which
 * renders the composer as a read-only/disabled input. Using a context — rather
 * than closing the value into the `Input` component passed to Stream's
 * <MessageInput> — keeps CustomMessageInputInner a stable reference, so the
 * value toggling re-renders the composer instead of unmounting and remounting
 * it (which would drop textarea focus and interrupt IME composition).
 */
const ComposerLockedContext = React.createContext(false)

/**
 * Distance from the bottom (px) still treated as "at the latest message", used
 * to decide whether a growing composer should re-pin the thread to the bottom
 * (MES-1303). Matches stream-chat-react's own `scrolledUpThreshold` default
 * (200) so composer growth keeps the thread pinned under the same condition
 * Stream already auto-scrolls a new message — anything closer than one message
 * would let a user resting just off the bottom fall through and get covered.
 */
const NEAR_BOTTOM_THRESHOLD_PX = 200

/**
 * Read the composer's locked (frozen-channel) state from inside a custom
 * composer supplied via `composerInput`. Returns true when the channel is
 * frozen; mirror the default composer by rendering the textarea read-only and
 * disabling the send control.
 */
export const useComposerLocked = (): boolean =>
  useContext(ComposerLockedContext)

const DefaultSendButton: React.FC<{
  sendMessage: () => void
  disabled?: boolean
  [key: string]: unknown
}> = ({ sendMessage, disabled, ...rest }) => (
  <button
    {...rest}
    type="button"
    aria-label="Send"
    disabled={disabled}
    // Keep the soft keyboard open after tapping send (MES-1320). Pressing a
    // button moves focus to it, blurring the textarea — on mobile that
    // dismisses the on-screen keyboard, forcing the visitor to re-tap the
    // composer for every message. preventDefault on pointerdown suppresses that
    // focus shift so the textarea stays focused; the click still fires (click is
    // derived from pointerup, not pointerdown), so `sendMessage` runs as normal.
    // pointerdown covers both touch and mouse, and keyboard activation (Enter/
    // Space → click) is unaffected.
    onPointerDown={(e) => e.preventDefault()}
    onClick={sendMessage}
  >
    <ArrowUpIcon weight="bold" className="size-4" />
  </button>
)

const CustomMessageInputInner: React.FC = () => {
  const disabled = useContext(ComposerLockedContext)
  const { handleSubmit } = useMessageInputContext()

  const hasSendableData = useMessageComposerHasSendableData()
  const isSendDisabled = disabled || !hasSendableData

  const {
    SendButton = DefaultSendButton,
    AttachmentPreviewList = DefaultAttachmentPreviewList,
  } = useComponentContext('CustomMessageInput')

  // Scroll the textarea back into view when the card shrinks under it. Browsers
  // reveal a newly focused element by themselves, and the caret while typing, so
  // neither needs help; the one thing nothing reveals is the box getting shorter
  // underneath the input — which is exactly what the keyboard does here, a beat
  // after the tap that raised it, once the host reports it and the cap below
  // follows the shrunken surface (MES-1037).
  const cardRef = React.useRef<HTMLDivElement>(null)
  React.useEffect(() => {
    const card = cardRef.current

    if (!card || typeof ResizeObserver === 'undefined') {
      return
    }

    const observer = new ResizeObserver(() => {
      if (card.contains(document.activeElement)) {
        card.scrollTop = card.scrollHeight
      }
    })
    observer.observe(card)

    return () => observer.disconnect()
  }, [])

  // Let the browser insert pasted text itself, so the composer grows to fit it
  // immediately (MES-1037). Stream's paste handler calls preventDefault and
  // feeds the text back through composer state asynchronously; TextareaComposer
  // then assigns `textarea.value` in a layout effect, which fires no input event
  // and so never triggers react-textarea-autosize's re-measure — leaving the
  // composer one line tall, with the pasted text hidden, until the next
  // keystroke happens to resize it. Letting the default action run puts pasted
  // text on exactly the path typed text takes, which measures correctly.
  //
  // It has to be a native listener that stops propagation rather than an
  // `onPaste` prop: TextareaComposer takes `onPaste` from the message-input
  // context and applies it after spreading our props, so ours would be
  // overridden, and a React handler on this card would run after the textarea's
  // anyway. Stopping the event here keeps it from reaching React's root, where
  // Stream's handler is dispatched from. Pastes carrying a file still go to
  // Stream, which uploads it as an attachment.
  React.useEffect(() => {
    const card = cardRef.current

    if (!card) {
      return
    }

    const passToBrowser = (event: ClipboardEvent) => {
      // `items`, not `files`: Safari never populates `clipboardData.files` on
      // paste, even for an image, only `items` — which is why Stream's own
      // handler reads files via `items` (`dataTransferItemsToFiles`) rather
      // than `.files`. Checking `.files.length` here would treat a pasted
      // screenshot as plain text on Safari and drop it silently instead of
      // handing it to Stream to upload.
      const hasFile = Array.from(event.clipboardData?.items ?? []).some(
        (item) => item.kind === 'file'
      )
      if (!hasFile) {
        event.stopPropagation()
      }
    }

    card.addEventListener('paste', passToBrowser)

    return () => card.removeEventListener('paste', passToBrowser)
  }, [])

  return (
    // This card is the composer's ONE scroll container: staged previews and the
    // textarea row scroll together past a cap, instead of the previews being
    // squeezed to keep the row in view (MES-1037). Nothing inside may shrink, or
    // the previews get cropped rather than scrolled — hence no `min-h-0` here or
    // on the previews wrapper, no `shrink-0` on the row, and no nested scroller
    // in CustomLinkPreviewList. Every child keeps its intrinsic height and the
    // overflow lands on this scrollport. `self-stretch` went with the squeeze it
    // fed: the parent `.message-input` row is `items-end`, so the card now sizes
    // to its content up to the cap. Stream's <MessageInput> renders its Input
    // directly (context providers only, no wrapper element), so this is a direct
    // flex item of that row.
    //
    // The cap is half the *keyboard-shrunk* surface height, because a cap that
    // doesn't shrink with the keyboard lets the card spill past the visible
    // viewport — which is the bug. No CSS unit tracks the visual viewport, so
    // `--messaging-surface-height` is an optional host contract, like this
    // package's own `--messaging-composer-height`: linktr.ee-profiles publishes
    // it on the messaging surface (an ancestor of this composer at runtime) from
    // its own `visualViewport` measurement, and it inherits in. Hosts that don't
    // fall back to `100dvh` — bounded, but not keyboard-aware. Deliberately not
    // measured here: the host owns viewport control and the package must not
    // fight it for it.
    //
    // The container itself carries no padding of its own: it used to (p-2),
    // but that inset now lives on the textarea as padding instead, so the
    // textarea's actual (clickable) box is as large as the container — save
    // for the small margin and the send button on the right (MES-1325).
    // Visually nothing moves; the container ends up exactly the size of the
    // textarea + send button row it contains.
    //
    // Some hosts portal staged-media thumbnails (e.g. ComposerPendingMediaPortal,
    // see this component's stories) straight into `.central-container` rather
    // than through this React tree, bypassing the two wrappers below. The
    // `[&>:not(...)]:p-2` variant restores the container's old inset for any
    // such foreign direct child, without re-adding it to our own wrappers.
    <div
      ref={cardRef}
      className="central-container flex flex-col min-w-0 w-full max-h-[calc(var(--messaging-surface-height,100dvh)*0.5)] overflow-y-auto overscroll-contain bg-white rounded-[1.5rem] shadow-[0_4px_16px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.04)] [&>:not(.messaging-composer-previews):not(.messaging-composer-row)]:p-2"
      data-dd-privacy="mask"
    >
      {/* empty:hidden collapses this row (padding, gap, and all) when none of
          the previews below have anything to render, so an idle composer has
          no leftover top inset before the textarea row. No bottom inset: the
          textarea's own py-3.5 already separates the last preview from the
          text, and adding one here double-spaces them now that both sit in the
          same scroll. */}
      <div className="messaging-composer-previews empty:hidden flex flex-col gap-2 px-2 pt-2">
        <QuotedMessagePreview />
        <CustomLinkPreviewList />
        <AttachmentPreviewList />
      </div>
      <div className="messaging-composer-row flex items-start">
        <TextareaComposer
          aria-disabled={disabled || undefined}
          // TextareaComposer wraps the real <textarea> in its own div, so
          // `containerClassName` (not `className`) is what joins the row's
          // flex layout; `leading-[0]` drops the line-height space an
          // inline-level parent otherwise reserves, keeping that wrapper
          // exactly the textarea's height rather than stretched by the row
          // (items-start above is a second guard against that same stretch).
          containerClassName="min-w-0 flex-1 leading-[0]"
          // bg-transparent: the container has no padding to inset it behind
          // anymore, so an opaque textarea would paint over its rounded corners.
          //
          // overflow-hidden makes "the card is the only scrollable region" a
          // property of the CSS rather than of the autosize arithmetic always
          // landing exactly right. A textarea's UA default is `overflow: auto`,
          // and TextareaComposer sizes itself by copying the computed style into
          // a detached measuring textarea on input, window resize and font load
          // only — so any width change it does not hear about (most obviously
          // this card gaining its own scrollbar, which narrows the textarea and
          // rewraps the text onto another line) leaves it a line short and
          // sprouts a second scrollbar inside the first. The caret stays visible
          // either way: browsers still scroll an overflow-hidden textarea to
          // reveal it, and the next keystroke re-fits the height.
          className="w-full resize-none overflow-hidden bg-transparent py-3.5 pl-4 pr-4 outline-none leading-5 placeholder:text-black/30 text-sm"
          // While this might usually be considered an anti-pattern, in most
          // cases, when a message thread is rendered, we want the input to
          // gain focus automatically.
          // eslint-disable-next-line jsx-a11y/no-autofocus
          autoFocus={!disabled}
          // Enter sends (Shift+Enter inserts a newline), so label the
          // on-screen keyboard's return key accordingly.
          enterKeyHint="send"
          // Grow to fit every line rather than scrolling the overflow inside
          // the textarea past a few of them: the card above is the one scroll
          // container, so a message long enough to outgrow it scrolls there, as
          // one piece with the previews. Explicit because TextareaComposer
          // defaults an absent `maxRows` to 1, not to "no cap".
          maxRows={Infinity}
          readOnly={disabled}
          tabIndex={disabled ? -1 : undefined}
        />
        {/* The container no longer has its own p-2, so this inset — and the
            mt-auto that anchors the button to the bottom as the textarea
            grows — lives on a wrapper instead of the button's own
            className: a host-supplied SendButton can (and, per our own
            stories, does) spread its own className over the one we pass in,
            which would otherwise silently drop this spacing. The sticky is
            here for the same reason.

            sticky: mt-auto alone anchors the button to the bottom of a row the
            textarea can grow taller than the card, carrying send out of the
            scroll's view while you are still typing. Sticking it to the
            scrollport keeps it reachable without a scroll, floating over the
            textarea's own column, so nothing reflows and no text runs under it.
            The offset matches mb-2 because sticky resolves against the border
            box, not the margin box: at bottom-0 the button would drop that
            inset and sit flush with the card's edge for as long as it stayed
            stuck. A sticky element cannot leave its containing block, which is
            the row — so scrolling all the way up to the previews takes the
            button off-screen with the row it belongs to, and it is pinned at
            every position where the row is in view, i.e. whenever you are
            typing.

            pt-2 gives the button the same inset above it that mb-2 gives below,
            and keeps it there while the card scrolls: 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 would otherwise meet
            it flush. The padding travels with the button, so the gap holds at
            every scroll position rather than only at rest. It needs no
            background to sit on: sticky cannot leave its containing block, so
            the button stays inside the row and a preview never passes behind
            it. */}
        <div className="sticky bottom-2 mt-auto mb-2 mr-2 shrink-0 pt-2">
          <SendButton
            sendMessage={handleSubmit}
            aria-label="Send"
            // before:-inset-2 extends the button's clickable area to 48x48
            // (MES-1325) via an invisible pseudo-element — clicks on it still
            // land on the button itself — without growing the 32x32 circle
            // `size-8`/`rounded-full`/`bg-[#121110]` visually describe.
            className="str-chat__send-button relative flex justify-center items-center rounded-full size-8 bg-[#121110] disabled:bg-[#F1F0EE] disabled:text-black/20 text-white focus-ring before:absolute before:-inset-2 before:content-['']"
            data-testid="send-button"
            disabled={isSendDisabled}
            type="button"
          />
        </div>
      </div>
    </div>
  )
}

export interface CustomMessageInputProps {
  renderActions?: () => React.ReactNode
  /**
   * Content rendered above the composer controls (and above the locked panel
   * when `disabled`). Used by the floating layout to keep conversation footer
   * content inside the absolutely positioned chrome so it is not covered.
   */
  renderHeader?: () => React.ReactNode
  renderFooter?: () => React.ReactNode
  /**
   * Replace the composer entirely with a non-interactive locked panel that
   * shows `disabledReason`. Used by the Linktree official channel, where the
   * linker cannot message Linktree from the inbox. Defaults to false.
   *
   * Distinct from the channel's `frozen` flag, which keeps the composer
   * rendered but read-only/dimmed.
   */
  disabled?: boolean
  /**
   * Explanatory text shown inside the locked panel. Only rendered when
   * `disabled` is true.
   */
  disabledReason?: string
  /**
   * Replace the inner composer — the textarea + send arrangement rendered
   * inside Stream's `<MessageInput>` — with a host-owned component. When
   * provided it is passed to Stream as the `Input` component, so it runs inside
   * the message-input context and can drive sending, attachments, and previews
   * via the Stream hooks (`useMessageInputContext`,
   * `useMessageComposerHasSendableData`, `TextareaComposer`, …). The outer
   * frozen/`renderActions`/`renderFooter` wrapper is unchanged; read
   * `useComposerLocked()` to honour the frozen state. Defaults to the built-in
   * composer.
   */
  composerInput?: React.ComponentType
}

const ComposerBackdrop: React.FC = () => (
  <div className="messaging-composer-backdrop" aria-hidden="true">
    <div className="messaging-composer-backdrop-blur" />
    <div className="messaging-composer-backdrop-blur" />
    <div className="messaging-composer-backdrop-blur" />
    <div className="messaging-composer-backdrop-blur" />
    <div className="messaging-composer-backdrop-blur" />
    <div className="messaging-composer-backdrop-gradient" />
  </div>
)

export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
  renderActions,
  renderHeader,
  renderFooter,
  disabled = false,
  disabledReason,
  composerInput,
}) => {
  const { channel } = useChannelStateContext()
  const isFrozen = channel?.data?.frozen === true
  const isFloating = Boolean(composerInput)
  const composerRef = React.useRef<HTMLDivElement>(null)

  React.useEffect(() => {
    if (!isFloating) {
      return
    }

    const composer = composerRef.current
    const layout = composer?.parentElement

    if (!composer || !layout) {
      return
    }

    // Track the composer height across resizes so we can re-pin only when it
    // actually grows (see below).
    let previousComposerHeight = composer.offsetHeight

    const updateComposerHeight = () => {
      // The floating composer overlays the message list; growing it only
      // reserves space via padding-bottom, which never moves the viewport, so
      // a taller composer creeps up over the last message (MES-1303). Capture
      // whether the thread is pinned to the latest message *before* the padding
      // grows, then re-pin afterwards.
      const nextComposerHeight = composer.offsetHeight
      // Only growth can cover the latest message: shrinks free up space and
      // width-only resizes (window/layout changes) cover nothing, so re-pinning
      // then would needlessly yank a user resting just off the bottom.
      const grew = nextComposerHeight > previousComposerHeight
      previousComposerHeight = nextComposerHeight

      // The scroll viewport is Stream's `.str-chat__list` (overflow-y: auto);
      // its child `.str-chat__message-list-scroll` holds the padding but has
      // overflow: visible and never scrolls. The redesigned empty state also
      // renders a placeholder `.str-chat__list`, so select the one that is
      // actually scrollable rather than the first match — that is also the only
      // one where re-pinning can do anything.
      const scroller =
        Array.from(
          layout.querySelectorAll<HTMLElement>('.str-chat__list')
        ).find((el) => el.scrollHeight > el.clientHeight) ?? null
      // Guarded on `wasAtBottom` so we never yank a user reading history.
      const wasAtBottom = scroller
        ? scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <
          NEAR_BOTTOM_THRESHOLD_PX
        : false

      layout.style.setProperty(
        '--messaging-composer-height',
        `${nextComposerHeight}px`
      )

      // Reading scrollHeight forces a synchronous reflow, so it reflects the
      // padding-bottom we just set; scrolling to it pins the true bottom.
      if (grew && wasAtBottom && scroller) {
        scroller.scrollTop = scroller.scrollHeight
      }
    }

    updateComposerHeight()

    if (typeof ResizeObserver === 'undefined') {
      return
    }

    const resizeObserver = new ResizeObserver(updateComposerHeight)
    resizeObserver.observe(composer)

    return () => {
      resizeObserver.disconnect()
      layout.style.removeProperty('--messaging-composer-height')
    }
  }, [isFloating])

  // Linktree official channel: the composer is replaced by an info panel
  // explaining the linker cannot send messages on this thread.
  if (disabled) {
    if (!isFloating) {
      return (
        <>
          {renderHeader?.()}
          <div className="messaging-composer-locked-panel flex w-full flex-col items-center justify-center gap-3 px-6 py-4">
            {disabledReason ? (
              <p className="max-w-[345px] text-center text-xs font-normal leading-[1.3] tracking-[0.12px] text-black/40">
                {disabledReason}
              </p>
            ) : null}
          </div>
          {renderFooter?.()}
        </>
      )
    }

    return (
      <div
        ref={composerRef}
        className="messaging-composer-chrome messaging-composer-chrome--floating"
      >
        <ComposerBackdrop />
        <div className="relative z-10 flex flex-col">
          {renderHeader?.()}
          <div className="messaging-composer-locked-panel flex w-full flex-col items-center justify-center gap-3 px-6 py-4">
            {disabledReason ? (
              <p className="max-w-[345px] text-center text-xs font-normal leading-[1.3] tracking-[0.12px] text-black/40">
                {disabledReason}
              </p>
            ) : null}
          </div>
          {renderFooter?.()}
        </div>
      </div>
    )
  }

  if (!isFloating) {
    return (
      <div className="flex min-h-0 flex-col gap-4 p-4">
        {renderHeader?.()}
        <div
          // @ts-expect-error Only React 19 onwards has `inert` in its types.
          inert={isFrozen ? '' : undefined}
          aria-disabled={isFrozen || undefined}
          className="message-input flex min-h-0 items-end gap-4 aria-disabled:opacity-40"
        >
          {renderActions && (
            <div className="flex h-12 shrink-0 items-center justify-center">
              {renderActions()}
            </div>
          )}
          <ComposerLockedContext.Provider value={isFrozen}>
            <MessageInput Input={CustomMessageInputInner} />
          </ComposerLockedContext.Provider>
        </div>
        {renderFooter?.()}
      </div>
    )
  }

  return (
    <div
      ref={composerRef}
      className="messaging-composer-chrome messaging-composer-chrome--floating"
    >
      <ComposerBackdrop />

      <div className="relative z-10 flex min-h-0 flex-col">
        {renderHeader?.()}
        <div className="flex min-h-0 flex-col gap-4 p-3 pb-5 md:p-5">
          <div
            // @ts-expect-error Only React 19 onwards has `inert` in its types.
            inert={isFrozen ? '' : undefined}
            aria-disabled={isFrozen || undefined}
            className="message-input flex min-h-0 items-end gap-4 aria-disabled:opacity-40"
          >
            {renderActions && (
              <div className="flex h-12 shrink-0 items-center justify-center">
                {renderActions()}
              </div>
            )}
            <ComposerLockedContext.Provider value={isFrozen}>
              <MessageInput Input={composerInput ?? CustomMessageInputInner} />
            </ComposerLockedContext.Provider>
          </div>
          {renderFooter?.()}
        </div>
      </div>
    </div>
  )
}
