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

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

import { AUDIO_BG_CLASS } from './components/_shared/CardThumbnail'

import LinkAttachment from '.'

describe('LinkAttachment.Received (link previews)', () => {
  // MES-1349 dropped the hairline border and both shadow layers — neither
  // appears on any composer or sent instance in the Figma frames. What this
  // case actually defends is the navigation contract, so it now asserts only
  // that.
  it('renders a link preview as an external anchor when a URL is set', async () => {
    renderWithProviders(
      <LinkAttachment.Received
        title="Brie’s FAQ"
        description="Get answers"
        url="tr.ee/briemix"
      />
    )
    const root = await screen.findByTestId('link-attachment')
    expect(root.tagName).toBe('A')
    expect(root.getAttribute('href')).toBe('https://tr.ee/briemix')
    expect(root.getAttribute('target')).toBe('_blank')
    expect(root.getAttribute('rel')).toBe('noopener noreferrer')
  })

  it('renders as a button when no URL is set and onClick is provided', async () => {
    const handleClick = vi.fn()
    renderWithProviders(
      <LinkAttachment.Received
        title="Featured image"
        thumbnailUrl="https://cdn.example.com/picture.jpg"
        onClick={handleClick}
      />
    )
    const root = await screen.findByTestId('link-attachment')
    expect(root.tagName).toBe('BUTTON')
    fireEvent.click(root)
    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('routes the card-level onClick through the CTA when one is set', async () => {
    const onClick = vi.fn()
    const ctaOnClick = vi.fn()
    renderWithProviders(
      <LinkAttachment.Received
        title="FAQ"
        description="Get answers"
        cta={{ label: 'View FAQs', onClick: ctaOnClick }}
        onClick={onClick}
      />
    )
    fireEvent.click(await screen.findByText('View FAQs'))
    expect(onClick).toHaveBeenCalledTimes(1)
    expect(ctaOnClick).toHaveBeenCalledTimes(1)
  })

  it('truncates the description to a single line by default', async () => {
    renderWithProviders(
      <LinkAttachment.Received
        title="Official"
        description="A longer body of prose that would wrap onto several lines"
      />
    )
    const description = await screen.findByText(
      'A longer body of prose that would wrap onto several lines'
    )
    expect(description).toHaveClass('truncate')
    expect(description).not.toHaveStyle({ display: '-webkit-box' })
  })

  it('clamps the description to descriptionLines lines when greater than 1', async () => {
    renderWithProviders(
      <LinkAttachment.Received
        title="Official"
        description="A longer body of prose that would wrap onto several lines"
        descriptionLines={3}
      />
    )
    const description = await screen.findByText(
      'A longer body of prose that would wrap onto several lines'
    )
    expect(description).not.toHaveClass('truncate')
    expect(description).toHaveStyle({
      display: '-webkit-box',
      WebkitLineClamp: '3',
    })
  })

  it('renders the filled (primary) CTA when cta.variant is omitted', async () => {
    renderWithProviders(
      <LinkAttachment.Received
        title="Welcome"
        description="Add a personal welcome message"
        cta={{ label: 'Add now', onClick: vi.fn() }}
      />
    )
    const button = await screen.findByText('Add now')
    expect(button.className).toContain('bg-[#121110]')
    expect(button.className).toContain('text-white')
  })

  it('renders the subtle (secondary) CTA when cta.variant is "secondary"', async () => {
    renderWithProviders(
      <LinkAttachment.Received
        title="Welcome"
        description="Add a personal welcome message"
        cta={{ label: 'Add now', onClick: vi.fn(), variant: 'secondary' }}
      />
    )
    const button = await screen.findByText('Add now')
    expect(button.className).toContain('bg-black/[0.06]')
    expect(button.className).toContain('text-[#121110]')
  })
})

describe('LinkAttachment.Sent', () => {
  it('renders the dark variant as an external link when url is set', async () => {
    const handleClick = vi.fn()
    renderWithProviders(
      <LinkAttachment.Sent
        title="My Playlist"
        description="A handpicked workout mix"
        url="tr.ee/briemix"
        onClick={handleClick}
      />
    )
    const link = await screen.findByRole('link', { name: /My Playlist/i })
    expect(link.tagName).toBe('A')
    expect(link.getAttribute('href')).toBe('https://tr.ee/briemix')
    expect(link.getAttribute('target')).toBe('_blank')
    expect(link.getAttribute('rel')).toBe('noopener noreferrer')
    fireEvent.click(link)
    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('stays non-interactive when no url is set', async () => {
    renderWithProviders(
      <LinkAttachment.Sent
        title="My Playlist"
        description="A handpicked workout mix"
      />
    )
    expect(await screen.findByText('My Playlist')).toBeInTheDocument()
    expect(screen.queryByRole('button')).toBeNull()
    expect(screen.queryByRole('link')).toBeNull()
  })

  it('lets the CTA own navigation when one is set (shell stays non-interactive)', async () => {
    renderWithProviders(
      <LinkAttachment.Sent
        title="FAQ"
        description="Get answers"
        url="tr.ee/briemix"
        cta={{ label: 'View FAQs', href: 'tr.ee/faq' }}
      />
    )
    expect(await screen.findByText('FAQ')).toBeInTheDocument()
    const links = screen.getAllByRole('link')
    expect(links).toHaveLength(1)
    expect(links[0].getAttribute('href')).toBe('https://tr.ee/faq')
    expect(links[0]).toHaveTextContent('View FAQs')
  })

  it('keeps a secondary CTA legible on an audio card (uses the painted light surface)', async () => {
    // A Sent audio card paints the light AUDIO_BG_CLASS even though it is a
    // "dark" card, so the secondary CTA must follow the actual surface (dark
    // text on light) rather than the nominal `dark` variant (white-on-white).
    renderWithProviders(
      <LinkAttachment.Sent
        title="My track"
        mimeType="audio/mpeg"
        sourceUrl="https://cdn.example.com/track.mp3"
        cta={{ label: 'Add now', onClick: vi.fn(), variant: 'secondary' }}
      />
    )
    const button = await screen.findByText('Add now')
    expect(button.className).toContain('bg-black/[0.06]')
    expect(button.className).toContain('text-[#121110]')
    expect(button.className).not.toContain('text-white')
  })
})

describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => {
  const ACCENT = '#7008E7'
  // The near-white dominant colour from the MES-1388 report. Painted raw it put
  // white ink on 1.09:1; derived, it is legible on both sides.
  const PALE_ACCENT = '#FCF5E3'
  const CARD_PROPS = {
    title: 'Yoga on Ice with Brie Nala',
    url: 'tr.ee/briewebsite',
    thumbnailUrl: 'https://cdn.example.com/yoga.jpg',
  }

  // The colour derivation itself is CSS (`oklch(from …)` in styles.css) and is
  // not computed by jsdom, so these tests assert the contract React owns: the
  // raw extracted hex is published as `--accent` (and dropped when there is no
  // visible hero), leaving the surface derivation to the browser.
  const accentOf = (element: HTMLElement) =>
    element.style.getPropertyValue('--accent')

  it('publishes the accent for the sender and inks the chin white', async () => {
    renderWithProviders(
      <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
    )
    const root = await screen.findByRole('link')
    expect(accentOf(root)).toBe(ACCENT)
    // No inline background: the surface is derived from `--accent` in CSS, the
    // raw accent is never painted verbatim.
    expect(root.style.backgroundColor).toBe('')
    expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
  })

  it('publishes the accent for the receiver and inks the chin near-black', async () => {
    renderWithProviders(
      <LinkAttachment.Received {...CARD_PROPS} accentColor={ACCENT} />
    )
    const root = await screen.findByRole('link')
    expect(accentOf(root)).toBe(ACCENT)
    expect(root.style.backgroundColor).toBe('')
    expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
  })

  it('colours both sides from one extracted colour, never rejecting it', async () => {
    // The pale report colour and the vivid one both paint, on both sides — the
    // feature adds colour to every card with an image rather than half of them.
    for (const accentColor of [ACCENT, PALE_ACCENT]) {
      const sent = renderWithProviders(
        <LinkAttachment.Sent {...CARD_PROPS} accentColor={accentColor} />
      )
      expect(accentOf(await screen.findByRole('link'))).toBe(accentColor)
      sent.unmount()

      const received = renderWithProviders(
        <LinkAttachment.Received {...CARD_PROPS} accentColor={accentColor} />
      )
      expect(accentOf(await screen.findByRole('link'))).toBe(accentColor)
      received.unmount()
    }
  })

  it('falls back to a self-contained fill, not a consumer theme token', async () => {
    renderWithProviders(<LinkAttachment.Sent {...CARD_PROPS} />)
    const root = await screen.findByRole('link')
    expect(accentOf(root)).toBe('')

    // The fill has to survive in consumers that do not share our Tailwind
    // theme. This package ships no utilities, so consumers generate our classes
    // against their own preset: `bg-shade` compiled here but was dropped in
    // frontyard (Arbor preset, no `shade` colour), leaving the chin transparent
    // under the own-message `color: white` (MES-1388). An arbitrary value is
    // generated from the class name alone, so assert that shape — not the
    // literal colour, which design may change.
    const fill = root.className
      .split(/\s+/)
      .find((c) => c.startsWith('bg-') && c !== 'bg-white')
    expect(fill).toBeDefined()
    expect(fill).toMatch(/^bg-\[/)
  })

  it('treats an unparseable accent as no accent', async () => {
    renderWithProviders(
      <LinkAttachment.Sent {...CARD_PROPS} accentColor="rebeccapurple" />
    )
    const root = await screen.findByRole('link')
    expect(accentOf(root)).toBe('')
    // Ink still comes from the side — here the sender's fallback fill.
    expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
  })

  it('drops the accent when the hero image fails to load', async () => {
    renderWithProviders(
      <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
    )
    const root = await screen.findByRole('link')
    expect(accentOf(root)).toBe(ACCENT)

    // A surface derived from an image the viewer cannot see is a lie, so the
    // card falls all the way back rather than keeping a colour with no source.
    fireEvent.error(screen.getByRole('img'))
    expect(accentOf(root)).toBe('')
    expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
  })

  it('drops the accent when a playable video poster fails to load', () => {
    // A video's poster is its hero, but `<video>` raises no event when one
    // fails — its own `error` is about the media resource — so `CardThumbnail`
    // verifies the poster with a hidden probe `<img>`. Without that, this card
    // stayed accent-painted over a bare `bg-black` box, which is exactly the
    // claim-without-a-source the gate exists to prevent.
    const { container } = renderWithProviders(
      <LinkAttachment.Sent
        {...CARD_PROPS}
        mimeType="video/mp4"
        sourceUrl="https://cdn.example.com/clip.mp4"
        accentColor={ACCENT}
      />
    )
    const shell = container.querySelector(
      '[class*="select-none"]'
    ) as HTMLElement
    expect(shell.style.getPropertyValue('--accent')).toBe(ACCENT)
    expect(container.querySelector('video')).toHaveAttribute('poster')

    fireEvent.error(container.querySelector('img') as HTMLImageElement)

    expect(shell.style.getPropertyValue('--accent')).toBe('')
    expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
    // The failed poster is dropped with the accent, so the player falls back to
    // its own first frame instead of retrying a 404.
    expect(container.querySelector('video')).not.toHaveAttribute('poster')
  })

  it('leaves a playable audio card on its flat fill, and inks that fill dark', async () => {
    // The accent stays out: the hero collapses to the native player, so artwork
    // never appears and a colour taken from it would describe an image the
    // viewer cannot see (MES-1349). Colouring audio from its artwork belongs to
    // the audio tickets, not here.
    //
    // The ink does change. `AUDIO_BG_CLASS` is near-white and is applied to a
    // *sent* card, whose variant ink is white — 1.13:1, invisible. Ink now
    // follows the surface the card was given rather than the variant (MES-1388).
    const AUDIO = {
      mimeType: 'audio/mpeg',
      sourceUrl: 'https://cdn.example.com/clip.mp3',
    }

    const { container, unmount } = renderWithProviders(
      <LinkAttachment.Sent {...CARD_PROPS} {...AUDIO} accentColor={ACCENT} />
    )
    const sent = container.firstElementChild as HTMLElement
    expect(sent.style.getPropertyValue('--accent')).toBe('')
    expect(sent.className).toContain(AUDIO_BG_CLASS)
    expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' })
    expect(screen.queryByRole('img')).toBeNull()
    unmount()

    renderWithProviders(
      <LinkAttachment.Received
        {...CARD_PROPS}
        {...AUDIO}
        accentColor={ACCENT}
      />
    )
    const received = await screen.findByTestId('link-attachment')
    expect(received.style.getPropertyValue('--accent')).toBe('')
    expect(received.className).toContain(AUDIO_BG_CLASS)
  })

  it('drops the accent when the featured hero has no image to show', async () => {
    // Only the type-icon placeholder renders, so there is nothing the accent
    // could plausibly have been extracted from.
    renderWithProviders(
      <LinkAttachment.Sent
        {...CARD_PROPS}
        thumbnailUrl={undefined}
        accentColor={ACCENT}
      />
    )
    const root = await screen.findByRole('link')
    expect(accentOf(root)).toBe('')
    // Variant fill, asserted by shape not literal — see the self-contained-fill
    // test above for why this must stay an arbitrary value (MES-1388).
    expect(root.className).toMatch(/bg-\[/)
    expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' })
  })

  it('normalizes a hashless accent so the derivation actually resolves', async () => {
    // The extractor returns a bare hex often enough that dropping it would lose
    // the colour silently — `oklch(from 7008E7 …)` never resolves, so the
    // published `--accent` must carry the `#`.
    const hashless = renderWithProviders(
      <LinkAttachment.Sent {...CARD_PROPS} accentColor="7008E7" />
    )
    expect(accentOf(await screen.findByRole('link'))).toBe('#7008E7')
    hashless.unmount()

    renderWithProviders(
      <LinkAttachment.Sent {...CARD_PROPS} accentColor={ACCENT} />
    )
    expect(accentOf(await screen.findByRole('link'))).toBe('#7008E7')
  })

  it('lets a loading status outrank the audio hero on all three cards', () => {
    // `status` and `mimeType`/`sourceUrl` are independent public props, and a
    // caller can set both. Before this, `CardThumbnail`'s audio fast path ran
    // ahead of the loading branch, so Sent/Received mounted a live `<audio>`
    // player beside `CardBody`'s skeleton bars and Composer's own audio path
    // dropped the skeleton and its `aria-busy` region altogether.
    const LOADING_AUDIO = {
      mimeType: 'audio/mpeg',
      sourceUrl: 'https://cdn.example.com/clip.mp3',
      status: 'loading' as const,
    }

    for (const card of [
      <LinkAttachment.Sent key="s" {...LOADING_AUDIO} />,
      <LinkAttachment.Received key="r" {...LOADING_AUDIO} />,
      <LinkAttachment.Composer
        key="c"
        {...LOADING_AUDIO}
        onDismiss={() => {}}
      />,
    ]) {
      const { container, unmount } = renderWithProviders(card)

      expect(container.querySelector('audio')).toBeNull()
      const busy = screen.getByLabelText('Loading link preview')
      expect(busy).toHaveAttribute('aria-busy', 'true')
      // The skeleton keeps its 250px box: the audio shell is intrinsic-height,
      // and a `flex-1` hero inside it would collapse to zero.
      expect(busy.className).toContain('h-[250px]')
      expect(container.querySelectorAll('.animate-pulse')).toHaveLength(3)

      unmount()
    }
  })

  it('paints the bubble tail in the accent for the last card in a run only', async () => {
    const { unmount } = renderWithProviders(
      <LinkAttachment.Sent
        {...CARD_PROPS}
        accentColor={ACCENT}
        groupPosition="end"
      />
    )
    // The tail paints outside the shell's own box, so it can't inherit the
    // shell's computed background — it publishes the same `--accent` and derives
    // the surface itself in CSS, or the tail and the card would disagree.
    const card = await screen.findByRole('link')
    const tail = screen.getByTestId('link-card-tail')
    expect(tail.style.getPropertyValue('--accent')).toBe(
      card.style.getPropertyValue('--accent')
    )
    expect(tail.style.getPropertyValue('--accent')).toBe(ACCENT)
    unmount()

    // Mid-run the following bubble owns the tail.
    renderWithProviders(
      <LinkAttachment.Sent
        {...CARD_PROPS}
        accentColor={ACCENT}
        groupPosition="middle"
      />
    )
    expect(screen.queryByTestId('link-card-tail')).toBeNull()
  })

  it('falls back to the variant fill for the tail when there is no accent', async () => {
    // No accent published, so no `--accent`: the tail's `[data-variant='dark']`
    // rule in styles.css paints the sender's plain `#1e2330` bubble fill (the
    // computed value isn't observable in jsdom — see the note on `accentOf`).
    renderWithProviders(<LinkAttachment.Sent {...CARD_PROPS} />)
    const tail = await screen.findByTestId('link-card-tail')
    expect(tail.style.getPropertyValue('--accent')).toBe('')
    expect(tail).toHaveAttribute('data-variant', 'dark')
  })

  it('exposes the loading skeleton as a labelled busy region', async () => {
    // MES-1358's composer test resolves the loading state through this
    // accessible name, so the wording is a cross-ticket contract.
    renderWithProviders(
      <LinkAttachment.Composer status="loading" onDismiss={() => {}} />
    )
    const busy = await screen.findByLabelText('Loading link preview')
    expect(busy).toHaveAttribute('aria-busy', 'true')
    expect(screen.queryByRole('img')).toBeNull()
  })

  it('exposes the composer URL line through its stable test hook', async () => {
    renderWithProviders(
      <LinkAttachment.Composer
        title="Brie’s FAQ"
        url="https://tr.ee/briemix"
        onDismiss={() => {}}
      />
    )
    expect(await screen.findByTestId('link-attachment-url')).toHaveTextContent(
      'https://tr.ee/briemix'
    )
  })
})

const AUDIO = {
  mimeType: 'audio/mpeg',
  sourceUrl: 'https://cdn.example.com/clip.mp3',
}
const VIDEO = {
  mimeType: 'video/mp4',
  sourceUrl: 'https://cdn.example.com/clip.mp4',
}
const HERO = 'https://cdn.example.com/yoga.jpg'

// Sent / Composer cards carry no test hook of their own; the shell is the
// element that owns the card chrome classes.
const shellOf = (container: HTMLElement) =>
  container.querySelector('.messaging-link-card') as HTMLElement

describe('LinkAttachment.Composer chrome', () => {
  it('anchors the dismiss control to the hero on a featured card', () => {
    const onDismiss = vi.fn()
    const { container } = renderWithProviders(
      <LinkAttachment.Composer
        title="Brie’s FAQ"
        thumbnailUrl={HERO}
        onDismiss={onDismiss}
      />
    )
    const dismiss = screen.getByLabelText('Dismiss attachment')
    expect(dismiss.parentElement?.className).toContain('absolute right-3 top-3')
    // Sits inside the hero region, not the shell corner.
    expect(container.querySelector('div.flex-1')?.contains(dismiss)).toBe(true)
    // 44x44 touch target without growing the 24px circle.
    expect(dismiss.className).toContain('before:-inset-[10px]')
    fireEvent.click(dismiss)
    expect(onDismiss).toHaveBeenCalledTimes(1)
  })

  it('moves the dismiss control to the shell corner on a classic card', () => {
    const { container } = renderWithProviders(
      <LinkAttachment.Composer
        title="Brie’s FAQ"
        thumbnailUrl={HERO}
        layout="classic"
        onDismiss={() => {}}
      />
    )
    expect(container.querySelector('img')).toBeNull()
    const shell = shellOf(container)
    expect(shell.contains(screen.getByLabelText('Dismiss attachment'))).toBe(
      true
    )
    // Classic cards size to their content.
    expect(shell.className).not.toContain('h-[250px]')
    expect(shell.className).toContain('block')
  })

  it('defaults to the featured layout with its hero and fixed box', () => {
    const { container } = renderWithProviders(
      <LinkAttachment.Composer title="Brie’s FAQ" thumbnailUrl={HERO} />
    )
    expect(container.querySelector('img')?.getAttribute('src')).toBe(HERO)
    expect(shellOf(container).className).toContain('h-[250px]')
  })

  it('collapses an audio composer card to the player plus an inline dismiss', () => {
    const { container } = renderWithProviders(
      <LinkAttachment.Composer
        title="My track"
        url="tr.ee/briemix"
        {...AUDIO}
        onDismiss={() => {}}
      />
    )
    const shell = shellOf(container)
    expect(shell.className).toContain(AUDIO_BG_CLASS)
    expect(container.querySelector('audio')).not.toBeNull()
    // No chin on this path, so the URL line goes with it.
    expect(screen.queryByTestId('link-attachment-url')).toBeNull()
    const dismiss = screen.getByLabelText('Dismiss attachment')
    // Inline sibling of the native controls: no overlay halo on top of them.
    expect(dismiss.className).not.toContain('before:-inset-[10px]')
    expect(dismiss.parentElement?.className).not.toContain('absolute')
  })

  it('lets the consumer name the dismiss control', () => {
    renderWithProviders(
      <LinkAttachment.Composer
        title="Brie’s FAQ"
        onDismiss={() => {}}
        dismissLabel="Remove link"
      />
    )
    expect(screen.getByLabelText('Remove link')).toBeInTheDocument()
    expect(screen.queryByLabelText('Dismiss attachment')).toBeNull()
  })

  it('renders the edit affordance only when a handler is given', () => {
    const onEditClick = vi.fn()
    renderWithProviders(
      <LinkAttachment.Composer title="Brie’s FAQ" onEditClick={onEditClick} />
    )
    fireEvent.click(screen.getByLabelText('Edit attachment'))
    expect(onEditClick).toHaveBeenCalledTimes(1)
    expect(screen.queryByLabelText('Dismiss attachment')).toBeNull()
  })

  it('renders no affordances when the composer passes no handlers', () => {
    renderWithProviders(<LinkAttachment.Composer title="Brie’s FAQ" />)
    expect(screen.queryByRole('button')).toBeNull()
  })
})

describe('LinkAttachment interactivity contract', () => {
  it('keeps the Received shell non-interactive around playable media', () => {
    const onClick = vi.fn()
    renderWithProviders(
      <LinkAttachment.Received
        title="Clip"
        url="tr.ee/briemix"
        thumbnailUrl={HERO}
        {...VIDEO}
        onClick={onClick}
      />
    )
    // The native controls own clicks, so the shell is neither anchor nor button.
    const root = screen.getByTestId('link-attachment')
    expect(root.tagName).toBe('DIV')
    fireEvent.click(root)
    expect(onClick).not.toHaveBeenCalled()
  })

  it('keeps the Received shell non-interactive when a CTA owns the click', () => {
    renderWithProviders(
      <LinkAttachment.Received
        title="FAQ"
        url="tr.ee/briemix"
        cta={{ label: 'View FAQs' }}
      />
    )
    expect(screen.getByTestId('link-attachment').tagName).toBe('DIV')
  })

  it('treats a whitespace-only Received url as no url at all', () => {
    renderWithProviders(
      <LinkAttachment.Received title="Brie’s FAQ" url="   " />
    )
    const root = screen.getByTestId('link-attachment')
    expect(root.tagName).toBe('DIV')
    expect(root.hasAttribute('href')).toBe(false)
    expect(screen.queryByTestId('link-attachment-url')).toBeNull()
  })

  it('names the Received preview button after the title, then falls back', () => {
    const { unmount } = renderWithProviders(
      <LinkAttachment.Received
        title="Featured image"
        thumbnailUrl={HERO}
        onClick={() => {}}
      />
    )
    expect(screen.getByTestId('link-attachment')).toHaveAttribute(
      'aria-label',
      'Featured image'
    )
    unmount()

    renderWithProviders(
      <LinkAttachment.Received thumbnailUrl={HERO} onClick={() => {}} />
    )
    expect(screen.getByTestId('link-attachment')).toHaveAttribute(
      'aria-label',
      'Open attachment preview'
    )
  })

  it('ignores the Sent onClick when there is nothing to open', () => {
    const onClick = vi.fn()
    const { container } = renderWithProviders(
      <LinkAttachment.Sent title="My Playlist" onClick={onClick} />
    )
    const shell = shellOf(container)
    expect(shell.tagName).toBe('DIV')
    fireEvent.click(shell)
    expect(onClick).not.toHaveBeenCalled()
  })

  it('ignores the Sent onClick around playable media', () => {
    const onClick = vi.fn()
    const { container } = renderWithProviders(
      <LinkAttachment.Sent
        title="Clip"
        url="tr.ee/briemix"
        thumbnailUrl={HERO}
        {...VIDEO}
        onClick={onClick}
      />
    )
    const shell = shellOf(container)
    expect(shell.tagName).toBe('DIV')
    fireEvent.click(shell)
    expect(onClick).not.toHaveBeenCalled()
  })
})

describe('LinkAttachment bubble tail grouping', () => {
  it.each([
    ['single', true],
    ['end', true],
    ['first', false],
    ['middle', false],
  ] as const)(
    'paints the Received tail for groupPosition %s: %s',
    (groupPosition, expected) => {
      renderWithProviders(
        <LinkAttachment.Received
          title="Brie’s FAQ"
          url="tr.ee/briemix"
          groupPosition={groupPosition}
        />
      )
      expect(screen.queryByTestId('link-card-tail') != null).toBe(expected)
    }
  )

  it.each([
    ['single', true],
    ['end', true],
    ['first', false],
  ] as const)(
    'paints the Sent tail for groupPosition %s: %s',
    (groupPosition, expected) => {
      renderWithProviders(
        <LinkAttachment.Sent
          title="Brie’s FAQ"
          url="tr.ee/briemix"
          groupPosition={groupPosition}
        />
      )
      expect(screen.queryByTestId('link-card-tail') != null).toBe(expected)
    }
  )

  it('defaults both chat cards to the tail-bearing single position', () => {
    const { unmount } = renderWithProviders(
      <LinkAttachment.Received title="Brie’s FAQ" url="tr.ee/briemix" />
    )
    expect(screen.getByTestId('link-card-tail')).toHaveAttribute(
      'data-variant',
      'light'
    )
    unmount()

    renderWithProviders(
      <LinkAttachment.Sent title="Brie’s FAQ" url="tr.ee/briemix" />
    )
    expect(screen.getByTestId('link-card-tail')).toHaveAttribute(
      'data-variant',
      'dark'
    )
  })

  it('gives the audio player card no tail — it is not a bubble', () => {
    const { unmount } = renderWithProviders(
      <LinkAttachment.Sent title="My track" {...AUDIO} groupPosition="end" />
    )
    expect(screen.queryByTestId('link-card-tail')).toBeNull()
    unmount()

    renderWithProviders(
      <LinkAttachment.Received
        title="My track"
        {...AUDIO}
        groupPosition="end"
      />
    )
    expect(screen.queryByTestId('link-card-tail')).toBeNull()
  })
})

describe('LinkAttachment layout and surface', () => {
  it.each([
    ['Sent', LinkAttachment.Sent],
    ['Received', LinkAttachment.Received],
  ] as const)(
    'drops the %s hero and the fixed box for classic',
    (_side, Card) => {
      const { container } = renderWithProviders(
        <Card
          title="Brie’s FAQ"
          url="tr.ee/briemix"
          thumbnailUrl={HERO}
          layout="classic"
        />
      )
      expect(container.querySelector('img')).toBeNull()
      const shell = shellOf(container)
      expect(shell.className).not.toContain('h-[250px]')
      expect(shell.className).toContain('block')
    }
  )

  it.each([
    ['Sent', LinkAttachment.Sent],
    ['Received', LinkAttachment.Received],
  ] as const)('locks the featured %s card to the 250px box', (_side, Card) => {
    const { container } = renderWithProviders(
      <Card title="Brie’s FAQ" url="tr.ee/briemix" thumbnailUrl={HERO} />
    )
    expect(container.querySelector('img')?.getAttribute('src')).toBe(HERO)
    expect(shellOf(container).className).toContain('h-[250px]')
  })

  it.each([
    ['Sent', LinkAttachment.Sent],
    ['Received', LinkAttachment.Received],
  ] as const)(
    'sizes the %s audio card to its content on the audio surface',
    (_side, Card) => {
      const { container } = renderWithProviders(
        <Card title="My track" url="tr.ee/briemix" {...AUDIO} />
      )
      const shell = shellOf(container)
      expect(shell.className).toContain(AUDIO_BG_CLASS)
      expect(shell.className).not.toContain('h-[250px]')
      expect(container.querySelector('audio')).not.toBeNull()
    }
  )
})
