import classNames from 'classnames'
import React from 'react'
import type { Attachment, LocalMessage } from 'stream-chat'
import {
  MessageErrorIcon,
  MessageText,
  type MessageContextValue,
} from 'stream-chat-react'

import { optimizeMessagingAttachmentUrl } from '../../utils/cdnImageUrl'
import LinkAttachment from '../LinkAttachment'
import { isPlayableMedia } from '../LinkAttachment/components/_shared/CardThumbnail'
import { isLinkAttachment } from '../MediaMessage'
import MessageAttachment from '../MessageAttachment'
import type { BubbleGroupPosition } from '../MessageAttachment/types'

import { MessageTail } from './MessageTail'

type StreamMediaKind = 'image' | 'video' | 'audio' | 'pdf' | 'file'

export type AttachmentSegment =
  | { type: 'link'; attachments: Attachment[] }
  | { type: 'media'; kind: StreamMediaKind; attachments: Attachment[] }

function trimToUndefined(value?: string | null): string | undefined {
  const trimmed = value?.trim()
  return trimmed ? trimmed : undefined
}

/**
 * Width ÷ height of the source media, from the dimensions Stream stores on the
 * attachment, or `undefined` when they are absent or unusable.
 *
 * These are what let a single video card know its shape on first paint. The
 * alternative — probing `loadedmetadata` — would fetch video bytes for every
 * card in a thread, against the deliberate `preload: 'none'`, and a poster is
 * not a substitute because an uploaded clip may not have one.
 *
 * Both fields are optional on Stream's `Attachment` and are absent on anything
 * sent before the composer began writing them, so every caller must still cope
 * with `undefined` rather than assuming a ratio (MES-1353).
 */
function aspectRatioFrom(attachment: Attachment): number | undefined {
  const { original_width: width, original_height: height } = attachment
  if (typeof width !== 'number' || typeof height !== 'number') return undefined
  if (!Number.isFinite(width) || !Number.isFinite(height)) return undefined
  if (width <= 0 || height <= 0) return undefined

  return width / height
}

function normalizeLinkUrl(url: string): string {
  return url
    .trim()
    .replace(/^https?:\/\//i, '')
    .replace(/\/+$/, '')
    .toLowerCase()
}

export function linkCaptionDuplicatesAttachmentUrl(
  caption: string | undefined,
  attachmentUrl?: string
): boolean {
  if (!caption?.trim() || !attachmentUrl?.trim()) {
    return false
  }

  const normalizedCaption = normalizeLinkUrl(caption)
  const normalizedUrl = normalizeLinkUrl(attachmentUrl)

  if (normalizedCaption === normalizedUrl) {
    return true
  }

  if (!normalizedCaption.includes(normalizedUrl)) {
    return false
  }

  const remainder = normalizedCaption.split(normalizedUrl).join('')
  return !/[^\p{P}\p{Z}\p{C}]/u.test(remainder)
}

export function linkCardPropsFromStreamAttachment(a: Attachment) {
  const url = trimToUndefined(a.og_scrape_url) ?? trimToUndefined(a.title_link)
  const thumbnailUrl =
    trimToUndefined((a as { image_url?: string }).image_url) ??
    trimToUndefined((a as { thumb_url?: string }).thumb_url)
  const sourceUrl = trimToUndefined(a.asset_url)
  const mimeType = trimToUndefined(a.mime_type)

  return {
    title: trimToUndefined((a as { title?: string }).title),
    description: trimToUndefined((a as { text?: string }).text),
    url,
    thumbnailUrl: optimizeMessagingAttachmentUrl(thumbnailUrl),
    // Playable clip embedded in a link preview — lets LinkAttachment render its
    // native video/audio player in the hero instead of a static thumbnail.
    sourceUrl,
    layout:
      thumbnailUrl || isPlayableMedia(mimeType, sourceUrl)
        ? 'featured'
        : 'classic',
    mimeType,
    // Dominant colour of the image, resolved by the sender at stage time and
    // persisted on the attachment (`stream-custom-data.ts`), so every viewer
    // paints an identical chin without re-extracting — visitors in
    // linktr.ee-profiles cannot call the extraction query at all. Absent on
    // messages sent before MES-1349, which render the fallback chin.
    accentColor: trimToUndefined(a.accent_color),
    // A persisted attachment is by definition fully scraped.
    status: 'ready',
  } as const
}

function getAttachmentMediaKind(
  attachment: Attachment
): StreamMediaKind | null {
  if (isLinkAttachment(attachment)) return null

  if (attachment.type === 'image') {
    const imageUrl = (attachment as { image_url?: string }).image_url
    if (imageUrl || attachment.asset_url) return 'image'
    return null
  }

  if (attachment.type === 'video' && attachment.asset_url) return 'video'
  if (attachment.type === 'audio' && attachment.asset_url) return 'audio'

  if (attachment.type === 'file' && attachment.asset_url) {
    if (attachment.mime_type === 'application/pdf') return 'pdf'
    if (attachment.mime_type?.startsWith('audio/')) return 'audio'
    return 'file'
  }

  return null
}

export function buildOrderedAttachmentSegments(
  attachments: Attachment[] | undefined
): AttachmentSegment[] {
  if (!attachments?.length) return []

  const segments: AttachmentSegment[] = []

  const pushOrMergeMedia = (kind: StreamMediaKind, attachment: Attachment) => {
    const last = segments[segments.length - 1]
    if (last?.type === 'media' && last.kind === kind) {
      last.attachments.push(attachment)
      return
    }

    segments.push({ type: 'media', kind, attachments: [attachment] })
  }

  const pushOrMergeLink = (attachment: Attachment) => {
    const last = segments[segments.length - 1]
    if (last?.type === 'link') {
      last.attachments.push(attachment)
      return
    }

    segments.push({ type: 'link', attachments: [attachment] })
  }

  for (const attachment of attachments) {
    if (isLinkAttachment(attachment)) {
      pushOrMergeLink(attachment)
      continue
    }

    const kind = getAttachmentMediaKind(attachment)
    if (!kind) continue

    pushOrMergeMedia(kind, attachment)
  }

  return segments
}

function countMediaSegmentBubbles(segments: AttachmentSegment[]): number {
  return segments.filter((segment) => segment.type === 'media').length
}

function bubblePositionInRun(
  index: number,
  total: number,
  streamPosition: BubbleGroupPosition
): BubbleGroupPosition {
  if (total <= 1) return streamPosition
  if (index === 0) return 'first'
  if (index === total - 1) return 'end'
  return 'middle'
}

function imageSrcFromAttachment(attachment: Attachment): string {
  return (
    trimToUndefined((attachment as { image_url?: string }).image_url) ??
    trimToUndefined(attachment.asset_url) ??
    ''
  )
}

function messageAttachmentNamespaceForKind(kind: StreamMediaKind) {
  switch (kind) {
    case 'image':
      return MessageAttachment.Image
    case 'video':
      return MessageAttachment.Video
    case 'audio':
      return MessageAttachment.Audio
    case 'pdf':
      return MessageAttachment.Pdf
    default:
      return MessageAttachment.File
  }
}

// Per-attachment render props for a single media kind. Both the single-item and
// multi-item (cluster) paths shape each attachment the same way, so they share
// this builder.
function mediaItemForKind(
  kind: StreamMediaKind,
  attachment: Attachment
): Record<string, unknown> {
  switch (kind) {
    case 'image':
      return {
        src: imageSrcFromAttachment(attachment),
        alt: trimToUndefined((attachment as { title?: string }).title),
      }
    case 'video':
      return {
        src: trimToUndefined(attachment.asset_url) ?? '',
        poster: optimizeMessagingAttachmentUrl(attachment.thumb_url),
        mimeType: trimToUndefined(attachment.mime_type),
        // Stream carries the source dimensions and the composer writes them
        // at send time, so a card knows its shape on first paint — no poster
        // needed and no `loadedmetadata` probe, which would mean fetching
        // video bytes for every card in the thread against the deliberate
        // `preload: 'none'`. Same trick the audio card uses for `duration` /
        // `waveform_data`. This builder serves both the single and clustered
        // paths, so the grid tiles get the ratio here too (MES-1353).
        naturalAspectRatio: aspectRatioFrom(attachment),
      }
    case 'audio':
      return {
        src: trimToUndefined(attachment.asset_url) ?? '',
        mimeType: trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
        filename: trimToUndefined((attachment as { title?: string }).title),
      }
    case 'pdf':
      return {
        src: trimToUndefined(attachment.asset_url) ?? '',
        filename: trimToUndefined((attachment as { title?: string }).title),
        fileSize: (attachment as { file_size?: number }).file_size,
      }
    case 'file':
      return {
        src: trimToUndefined(attachment.asset_url) ?? '',
        filename: trimToUndefined((attachment as { title?: string }).title),
        fileSize: (attachment as { file_size?: number }).file_size,
        mimeType:
          trimToUndefined(attachment.mime_type) ?? 'application/octet-stream',
      }
  }
}

function buildMediaClusterRenderProps(
  kind: StreamMediaKind,
  attachments: Attachment[]
): Record<string, unknown> {
  if (attachments.length > 1) {
    return {
      items: attachments.map((attachment) =>
        mediaItemForKind(kind, attachment)
      ),
    }
  }

  const attachment = attachments[0]
  if (!attachment) return {}

  return mediaItemForKind(kind, attachment)
}

function MediaClusterBubble({
  groupPosition,
  isMyMessage,
  segment,
}: {
  groupPosition: BubbleGroupPosition
  isMyMessage: boolean
  segment: Extract<AttachmentSegment, { type: 'media' }>
}) {
  const TypeNamespace = messageAttachmentNamespaceForKind(segment.kind)
  const props = {
    ...buildMediaClusterRenderProps(segment.kind, segment.attachments),
    groupPosition,
  }

  if (isMyMessage) {
    return <TypeNamespace.Sent {...props} />
  }

  return <TypeNamespace.Received {...props} />
}

export interface StreamAttachmentMessageProps {
  groupPosition: BubbleGroupPosition
  isMyMessage: boolean
  message: LocalMessage
  renderText?: MessageContextValue['renderText']
}

const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
  groupPosition,
  isMyMessage,
  message,
  renderText,
}) => {
  const segments = buildOrderedAttachmentSegments(message.attachments)
  if (segments.length === 0) return null

  const totalMediaBubbles = countMediaSegmentBubbles(segments)
  const rawCaption = message.text?.trim()
  const linkSegment = segments.find((segment) => segment.type === 'link')
  const primaryLinkUrl =
    linkSegment?.type === 'link'
      ? linkCardPropsFromStreamAttachment(linkSegment.attachments[0] ?? {}).url
      : undefined
  const caption =
    rawCaption && linkCaptionDuplicatesAttachmentUrl(rawCaption, primaryLinkUrl)
      ? undefined
      : rawCaption

  let mediaBubbleIndex = 0
  const rows: React.ReactNode[] = []

  // A link card paints its own accent-coloured tail only when it is the very
  // last thing this message renders. A caption bubble follows when there is
  // one, and that bubble owns the tail (rendered below via `MessageTail`); and a
  // message mid-run has its tail on a later message. Anything else is a mid-run
  // card, which must stay tail-less.
  const linkTailPosition: BubbleGroupPosition = caption
    ? 'middle'
    : groupPosition
  // The trailing caption bubble carries the tail on single / last-in-run
  // messages, matching the in-channel text bubbles.
  const captionShowsTail = groupPosition === 'single' || groupPosition === 'end'

  const pushLinkRow = (
    attachment: Attachment,
    key: string,
    isFinalRow: boolean
  ) => {
    const Card = isMyMessage ? LinkAttachment.Sent : LinkAttachment.Received

    rows.push(
      <Card
        key={key}
        {...linkCardPropsFromStreamAttachment(attachment)}
        groupPosition={isFinalRow ? linkTailPosition : 'middle'}
      />
    )
  }

  const pushMediaCluster = (
    segment: Extract<AttachmentSegment, { type: 'media' }>,
    key: string
  ) => {
    const position = bubblePositionInRun(
      mediaBubbleIndex,
      totalMediaBubbles,
      groupPosition
    )

    mediaBubbleIndex += 1

    rows.push(
      <MediaClusterBubble
        key={key}
        groupPosition={position}
        isMyMessage={isMyMessage}
        segment={segment}
      />
    )
  }

  segments.forEach((segment, segmentIndex) => {
    const isFinalSegment = segmentIndex === segments.length - 1

    if (segment.type === 'link') {
      segment.attachments.forEach((attachment, attachmentIndex) => {
        pushLinkRow(
          attachment,
          `link-${segmentIndex}-${attachmentIndex}`,
          isFinalSegment && attachmentIndex === segment.attachments.length - 1
        )
      })
      return
    }

    pushMediaCluster(segment, `media-${segmentIndex}`)
  })

  return (
    <div className="str-chat__message-bubble-wrapper">
      <div
        className="str-chat__message-bubble"
        data-attachment-bubble="true"
        style={{
          background: 'transparent',
          borderRadius: 0,
          overflow: 'visible',
          padding: 0,
        }}
      >
        {rows}
        {!caption && <MessageErrorIcon />}
      </div>
      {caption ? (
        <div
          className={classNames('str-chat__message-bubble', {
            'relative isolate': captionShowsTail,
          })}
        >
          <MessageText message={message} renderText={renderText} />
          <MessageErrorIcon />
          {captionShowsTail && (
            <MessageTail side={isMyMessage ? 'me' : 'other'} />
          )}
        </div>
      ) : null}
    </div>
  )
}

export default StreamAttachmentMessage
