// `<CommentItem>` action-row + kebab structural tests. Locks down the
// row contract: text-only action strip (`Reply` · `Like / Liked · N` ·
// `{timeago}` · optional `View N replies →`), the rich media preview
// renders through `<StatusMediaPreview>` (not inline), and the right-
// column kebab branches on `viewerId === item.account.id` for self-vs-
// other items.
//
// Tamagui / zeego / vector-icons / expo-* are stubbed to passthrough
// host primitives so the rendered tree can be walked for content
// presence. The exact pixel positions / layouts are NOT in scope — the
// production component owns those, and the tests would just shadow the
// JSX. What's locked here is *behaviour*: which strings appear, which
// kebab keys are emitted, which conditions enable thumbnails.

import React from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'

// Tamagui mock — every primitive becomes a host-tag passthrough that
// renders its children. `useTheme` returns the minimum token surface the
// row + kebab read.
jest.mock('tamagui', () => {
  const React = require('react') as typeof import('react')
  const passthrough =
    (tag: string) =>
    ({ children, ...rest }: { children?: React.ReactNode; [k: string]: any }) =>
      React.createElement(tag, rest, children)
  // zeego's Menu is also re-exported under tamagui's namespace by some
  // patterns, but CommentItemKebab imports it from `tamagui` directly —
  // see `src/components/post/CommentItemKebab.tsx:10`. The mock has to
  // expose the full Menu.* shape the component relies on.
  const Menu = passthrough('Menu') as any
  Menu.Trigger = passthrough('Menu.Trigger')
  Menu.Content = passthrough('Menu.Content')
  Menu.Group = passthrough('Menu.Group')
  Menu.Item = ({ children, ...rest }: { children?: React.ReactNode; [k: string]: any }) =>
    React.createElement('Menu.Item', rest, children)
  Menu.ItemTitle = passthrough('Menu.ItemTitle')
  Menu.ItemIcon = ({ ios }: { ios?: { name?: string } }) =>
    React.createElement('Menu.ItemIcon', { ios }, null)
  return {
    Menu,
    Spinner: () => React.createElement('Spinner', null),
    Text: passthrough('Text'),
    View: passthrough('View'),
    XStack: passthrough('XStack'),
    YStack: passthrough('YStack'),
    useTheme: () => ({
      color: { val: '#000' },
      colorSecondary: { val: '#888' },
      colorTertiary: { val: '#aaa' },
      accent: { val: '#0af' },
      interactiveActive: { val: '#0066cc' },
      borderColor: { val: '#000' },
      background: { val: '#fff' },
    }),
  }
})

// Toast controller — only `show` is consulted by the kebab's `Copy link`.
const mockToastShow = jest.fn()
jest.mock('@tamagui/toast', () => ({
  useToastController: () => ({ show: mockToastShow }),
}))

// expo-clipboard / expo-router / expo-vector-icons surfaces. Only the
// kebab's onCopyLink reaches Clipboard.setStringAsync; only its
// onViewProfile / onAboutAccount / onEditThis call router.push; the
// Feather icon component is rendered to a stub element.
const mockClipboardSet = jest.fn()
jest.mock('expo-clipboard', () => ({
  setStringAsync: (s: string) => mockClipboardSet(s),
}))

const mockRouterPush = jest.fn()
jest.mock('expo-router', () => ({
  router: { push: (path: string) => mockRouterPush(path) },
}))

jest.mock('@expo/vector-icons', () => ({
  Feather: () => require('react').createElement('Feather', null),
}))

// `react-native` — the row reads Dimensions.get and uses Pressable.
// Keep both as host-shape stubs so the layout calc doesn't divide by
// undefined and the row's tap-pivot still surfaces a press handler we
// can exercise via the rendered tree.
jest.mock('react-native', () => {
  const React = require('react') as typeof import('react')
  return {
    Dimensions: { get: () => ({ width: 390, height: 844 }) },
    Pressable: ({
      children,
      ...rest
    }: {
      children?: React.ReactNode
      [k: string]: any
    }) => React.createElement('Pressable', rest, children),
    Platform: { OS: 'ios' },
    Share: { share: jest.fn() },
    Linking: { openURL: jest.fn() },
  }
})

jest.mock('expo-web-browser', () => ({ openBrowserAsync: jest.fn() }))

// Subordinate components — render their content as inert host nodes so
// the row's text content is observable. UserAvatar and ImageComponent
// each surface a recognisable stub element so thumbnail-presence
// assertions can find them.
jest.mock('src/components/common/UserAvatar', () => {
  const React = require('react') as typeof import('react')
  return {
    __esModule: true,
    default: ({ url }: { url?: string }) =>
      React.createElement('UserAvatar', { url }, null),
  }
})

jest.mock('src/components/common/EmojiText', () => {
  const React = require('react') as typeof import('react')
  return {
    __esModule: true,
    default: ({ text }: { text?: string }) =>
      React.createElement('EmojiText', null, text ?? ''),
  }
})

jest.mock('src/components/common/StatusContent', () => {
  const React = require('react') as typeof import('react')
  return {
    __esModule: true,
    default: ({ html }: { html?: string }) =>
      React.createElement('StatusContent', null, html ?? ''),
  }
})

jest.mock('src/components/common/AutolinkText', () => ({
  // The row only consults `onMentionPressMethod`; return a no-op handler.
  onMentionPressMethod: () => () => {},
}))

jest.mock('src/components/ImageComponent', () => {
  const React = require('react') as typeof import('react')
  return {
    __esModule: true,
    default: ({ source }: { source?: { uri?: string } }) =>
      React.createElement('ImageComponent', { uri: source?.uri }, null),
  }
})

// StatusMediaPreview wraps expo-image / expo-video / vector-icons /
// reanimated-carousel — none of which are in scope for these row tests.
// Stub it to a recognisable host element so thumbnail-presence assertions
// can find it via the first attachment's URL.
jest.mock('src/components/common/StatusMediaPreview', () => {
  const React = require('react') as typeof import('react')
  return {
    __esModule: true,
    default: ({ status }: { status: { media_attachments?: Array<{ url?: string }> } }) =>
      React.createElement(
        'StatusMediaPreview',
        { uri: status?.media_attachments?.[0]?.url },
        null
      ),
  }
})

// useWindowWidth is read by CommentItem's media-width calc — return a
// stable phone-sized value so the math doesn't divide by undefined.
jest.mock('src/hooks/useWindowWidth', () => ({
  useWindowWidth: () => 390,
}))

// `prettyCount` and `timeAgo` are pure helpers — keep the real
// implementations. `htmlToTextWithLineBreaks` (transitively imported via
// the threadStyle / utils chain) reaches expo-web-browser's native
// module; the openBrowserAsync mock above already neuters that.
type CommentItemModule = typeof import('../src/components/post/CommentItem')
let CommentItem: CommentItemModule['default']

beforeAll(() => {
  CommentItem = (require('../src/components/post/CommentItem') as CommentItemModule)
    .default
})

beforeEach(() => {
  mockToastShow.mockReset()
  mockClipboardSet.mockReset()
  mockRouterPush.mockReset()
})

type Item = {
  id: string
  url?: string
  uri?: string
  content?: string
  pf_type?: string
  reply_count?: number
  replies_count?: number
  favourites_count?: number
  favourited?: boolean
  created_at: string
  in_reply_to_id?: string | null
  account: {
    id: string
    username: string
    acct: string
    display_name?: string
    avatar?: string
    url?: string
    emojis?: unknown[]
  }
  media_attachments?: Array<{ url?: string; type?: string }>
}

function itemFixture(overrides: Partial<Item> = {}): Item {
  return {
    id: 'c1',
    url: 'https://test.example/web/post/c1',
    uri: 'https://test.example/posts/c1',
    content: 'hello world',
    reply_count: 0,
    favourites_count: 0,
    favourited: false,
    created_at: new Date(Date.now() - 5 * 60_000).toISOString(),
    account: {
      id: 'acct-other',
      username: 'tester',
      acct: 'tester@test.example',
      display_name: 'Tester',
      avatar: 'https://test.example/avatar.png',
      url: 'https://test.example/@tester',
      emojis: [],
    },
    ...overrides,
  }
}

function renderRow(opts: { item: Item; user?: { id: string }; depth?: number }): {
  root: ReactTestRenderer
  tree: any
} {
  const handlers = {
    onReply: jest.fn(),
    onLike: jest.fn(),
    onReport: jest.fn(),
    onDelete: jest.fn(),
    onShowLikes: jest.fn(),
    gotoProfile: jest.fn(),
    gotoUsernameProfile: jest.fn(),
    gotoHashtag: jest.fn(),
  }
  let root: ReactTestRenderer = null as unknown as ReactTestRenderer
  act(() => {
    root = create(
      React.createElement(CommentItem, {
        item: opts.item,
        user: opts.user ?? null,
        depth: opts.depth ?? 0,
        ...handlers,
      } as any)
    )
  })
  return { root, tree: root.toJSON() }
}

function extractText(node: any): string {
  if (node == null) return ''
  if (typeof node === 'string') return node
  if (Array.isArray(node)) return node.map(extractText).join(' ')
  if (typeof node === 'object' && 'children' in node) {
    return extractText(node.children)
  }
  return ''
}

function findByType(node: any, type: string): any[] {
  const out: any[] = []
  const walk = (n: any) => {
    if (!n) return
    if (Array.isArray(n)) {
      n.forEach(walk)
      return
    }
    if (typeof n !== 'object') return
    if (n.type === type) out.push(n)
    walk(n.children)
  }
  walk(node)
  return out
}

// React strips `key` from `props` (it's reconciliation metadata, not
// surface API), so each Menu.Item is identified instead by its
// `Menu.ItemTitle` text. The mapping below mirrors `CommentItemKebab.tsx`'s
// per-item title strings; if a future refactor renames a title, this
// table is the one place to update.
const TITLE_TO_KEY: Record<string, string> = {
  Share: 'share',
  'Copy link': 'copy-link',
  'View Profile': 'view-profile',
  'About this account': 'about',
  'Open in browser': 'browser',
  'View Likes': 'view-likes',
  Report: 'report',
  Edit: 'edit',
  Delete: 'delete',
}

function findMenuItemKeys(node: any): string[] {
  const items = findByType(node, 'Menu.Item')
  const keys: string[] = []
  for (const item of items) {
    const titles = findByType(item, 'Menu.ItemTitle')
    if (!titles.length) continue
    const title = extractText(titles[0]).trim()
    const k = TITLE_TO_KEY[title]
    if (k) keys.push(k)
  }
  return keys
}

describe('CommentItem action row', () => {
  it('always renders the Reply text label', () => {
    const { tree } = renderRow({ item: itemFixture() })
    expect(extractText(tree)).toContain('Reply')
  })

  it('renders "Like" with no count when not favourited and count is 0', () => {
    const { tree } = renderRow({
      item: itemFixture({ favourited: false, favourites_count: 0 }),
    })
    const text = extractText(tree)
    expect(text).toContain('Like')
    // Liked / count fragments should be absent.
    expect(text).not.toContain('Liked')
  })

  it('renders bare "Liked" (no count) when favourited but count is 0', () => {
    // Edge case: favouriting your own action moves the column to "Liked"
    // but the count is still 0 in some optimistic-update windows. The
    // pill should never read "Liked · 0" — that's the kind of zero-noise
    // copy bug a regression test should catch.
    const { tree } = renderRow({
      item: itemFixture({ favourited: true, favourites_count: 0 }),
    })
    const text = extractText(tree)
    expect(text).toContain('Liked')
    expect(text).not.toMatch(/Liked\s*·\s*0/)
  })

  it('renders "Liked · N" with the count appended when favourited and count > 0', () => {
    const { tree } = renderRow({
      item: itemFixture({ favourited: true, favourites_count: 3 }),
    })
    const text = extractText(tree)
    expect(text).toContain('Liked')
    expect(text).toContain('3')
  })

  it('renders a timestamp text', () => {
    // Don't pin the exact timeAgo string (it's locale + clock dependent);
    // just assert *some* text after Reply / Like that contains a digit
    // followed by a unit. The component's `timeAgo(item.created_at)` is
    // tested elsewhere — here we just verify it's surfaced.
    const { tree } = renderRow({ item: itemFixture() })
    const text = extractText(tree)
    expect(text).toMatch(/\d+\s*(s|m|h|d|w|y)/i)
  })

  it('does NOT render the row body as a whole-row Pressable navigation target', () => {
    // The row used to wrap its content in a Pressable that navigated to
    // `/post/[id]`; that was bad UX (text taps re-anchored unexpectedly).
    // Replacement is the explicit "View N replies →" link — verified
    // separately. No `onPress` props should hang off the outermost View
    // wrappers.
    const { tree } = renderRow({ item: itemFixture({ reply_count: 5 }) })
    // The top-level node is the row View itself; if anyone wraps it in a
    // Pressable in the future, that Pressable will be `tree`.
    const topNode = tree as { type?: string; props?: { onPress?: unknown } }
    expect(topNode?.type).not.toBe('Pressable')
  })

  it('renders a "View N replies →" affordance when reply_count > 0', () => {
    // The new explicit navigation link, conditional on the comment having
    // children. Pixelfed shape (reply_count).
    const onViewReplies = jest.fn()
    const tree = renderWithViewReplies({
      item: itemFixture({ reply_count: 5 }),
      onViewReplies,
    })
    const text = extractText(tree)
    expect(text).toMatch(/View\s+5\s+replies/i)
  })

  it('renders "View 1 reply" (singular) when reply_count === 1', () => {
    const tree = renderWithViewReplies({
      item: itemFixture({ reply_count: 1 }),
      onViewReplies: jest.fn(),
    })
    expect(extractText(tree)).toMatch(/View\s+1\s+reply\b/i)
  })

  it('hides the "View N replies" link when reply_count === 0', () => {
    const tree = renderWithViewReplies({
      item: itemFixture({ reply_count: 0 }),
      onViewReplies: jest.fn(),
    })
    expect(extractText(tree)).not.toMatch(/View\s+\d+\s+repl/i)
  })

  it('renders an explicit ancestor-style label when viewRepliesLabel is provided', () => {
    // Ancestors pass `viewRepliesLabel="Open this reply →"` so the link
    // appears even when reply_count is 0 — they're a chain leading up to
    // the anchor and the tap meaning differs from descendants.
    const tree = renderWithViewReplies({
      item: itemFixture({ reply_count: 0 }),
      onViewReplies: jest.fn(),
      viewRepliesLabel: 'Open this reply →',
    })
    expect(extractText(tree)).toContain('Open this reply')
  })

  it('renders a thumbnail via StatusMediaPreview when pf_type === "photo" (Pixelfed)', () => {
    const { tree } = renderRow({
      item: itemFixture({
        pf_type: 'photo',
        media_attachments: [{ url: 'https://test.example/photo.jpg' }],
      }),
    })
    const previews = findByType(tree, 'StatusMediaPreview')
    expect(previews.length).toBe(1)
    expect((previews[0].props as { uri?: string } | null)?.uri).toBe(
      'https://test.example/photo.jpg'
    )
  })

  it('renders a thumbnail via StatusMediaPreview when type === "image" (Mastodon)', () => {
    const { tree } = renderRow({
      item: itemFixture({
        media_attachments: [{ url: 'https://test.example/masto.jpg', type: 'image' }],
      }),
    })
    const previews = findByType(tree, 'StatusMediaPreview')
    expect(previews.length).toBe(1)
    expect((previews[0].props as { uri?: string } | null)?.uri).toBe(
      'https://test.example/masto.jpg'
    )
  })

  it('routes video attachments through StatusMediaPreview as well', () => {
    // Previously the row hid videos entirely. The new component handles
    // them; the test asserts the row delegates rather than dropping.
    const { tree } = renderRow({
      item: itemFixture({
        media_attachments: [{ url: 'https://test.example/clip.mp4', type: 'video' }],
      }),
    })
    expect(findByType(tree, 'StatusMediaPreview').length).toBe(1)
  })

  it('renders no media stub when there are no attachments', () => {
    const { tree } = renderRow({ item: itemFixture() })
    expect(findByType(tree, 'StatusMediaPreview').length).toBe(0)
  })
})

function renderWithViewReplies(opts: {
  item: Item
  onViewReplies: (item: Item) => void
  user?: { id: string } | null
  viewRepliesLabel?: string
}): any {
  const handlers = {
    onReply: jest.fn(),
    onLike: jest.fn(),
    onReport: jest.fn(),
    onDelete: jest.fn(),
    onShowLikes: jest.fn(),
    onViewReplies: opts.onViewReplies,
    viewRepliesLabel: opts.viewRepliesLabel,
    gotoProfile: jest.fn(),
    gotoUsernameProfile: jest.fn(),
    gotoHashtag: jest.fn(),
  }
  let root: ReactTestRenderer = null as unknown as ReactTestRenderer
  act(() => {
    root = create(
      React.createElement(CommentItem, {
        item: opts.item,
        user: opts.user ?? null,
        depth: 0,
        ...handlers,
      } as any)
    )
  })
  return root.toJSON()
}

describe('CommentItemKebab structure', () => {
  it('emits Share, Copy link, View Profile, About, Open in browser keys for all viewers', () => {
    const { tree } = renderRow({
      item: itemFixture(),
      user: { id: 'someone-else' },
    })
    const keys = findMenuItemKeys(tree)
    // These five keys are unconditional — every kebab variant carries
    // them. Locking the set protects against silent parity drift with
    // FeedPost's anchor kebab (`FeedPost.tsx:835-913`).
    expect(keys).toEqual(
      expect.arrayContaining(['share', 'copy-link', 'view-profile', 'about', 'browser'])
    )
  })

  it('emits "report" when the comment is by another user', () => {
    const { tree } = renderRow({
      item: itemFixture({ account: { ...itemFixture().account, id: 'other' } }),
      user: { id: 'me' },
    })
    expect(findMenuItemKeys(tree)).toContain('report')
  })

  it('omits "report" when the viewer authored the comment (no self-report)', () => {
    const { tree } = renderRow({
      item: itemFixture({
        account: { ...itemFixture().account, id: 'me' },
      }),
      user: { id: 'me' },
    })
    const keys = findMenuItemKeys(tree)
    expect(keys).not.toContain('report')
    // Self gets Edit + Delete instead.
    expect(keys).toContain('edit')
    expect(keys).toContain('delete')
  })

  it('groups Edit + Delete only when the viewer authored the comment', () => {
    const { tree } = renderRow({
      item: itemFixture({
        account: { ...itemFixture().account, id: 'me' },
      }),
      user: { id: 'me' },
    })
    const groups = findByType(tree, 'Menu.Group')
    expect(groups.length).toBe(1)
    const groupedKeys = findMenuItemKeys(groups[0])
    expect(groupedKeys).toEqual(expect.arrayContaining(['edit', 'delete']))
  })

  it('shows "View Likes" only when favourites_count > 0', () => {
    const noLikes = renderRow({
      item: itemFixture({ favourites_count: 0 }),
      user: { id: 'me' },
    })
    expect(findMenuItemKeys(noLikes.tree)).not.toContain('view-likes')

    const withLikes = renderRow({
      item: itemFixture({ favourites_count: 3 }),
      user: { id: 'me' },
    })
    expect(findMenuItemKeys(withLikes.tree)).toContain('view-likes')
  })

  it('emits unique menu keys (no duplicate slots)', () => {
    // FeedPost's kebab carries unique keys; the comment kebab should too.
    // A duplicate would silently break zeego's keyed render and crash on
    // navigation in some builds — the underlying bug is hard to debug
    // mid-flight, easy to assert here.
    const { tree } = renderRow({
      item: itemFixture({
        favourites_count: 2,
        account: { ...itemFixture().account, id: 'me' },
      }),
      user: { id: 'me' },
    })
    const keys = findMenuItemKeys(tree)
    expect(new Set(keys).size).toBe(keys.length)
  })
})
