// `useComposer` reply-aware arg path tests. Locks the four-leg visibility
// cascade (lockedAccount > parent-non-public > user-default > server-default),
// the Pixelfed `mediaRequired` carve-out for replies (text-only replies are
// `canPost=true` even when the server-default would otherwise demand a
// media attachment), and the `params.in_reply_to_id` flow into
// `postNewStatus`. The cascade is the single most user-visible surface in
// the reply pivot — getting it wrong widens a private convo to public, or
// blocks a reply behind "you must add an image" that doesn't apply to
// replies on either server.
//
// Mocking strategy: `useComposer` reaches into ~10 surfaces that each have
// native-module side effects (`react-native-mmkv` via the queries modules,
// `expo-share-intent`, `expo-router`, the api layer, the media pipeline,
// the tapestry emitter). Rather than building a full QueryClientProvider
// + AccountStore + MMKV stack, we mock at the import boundary so the hook
// renders in pure-JS land — `useQuery` is replaced with a key-dispatched
// stub that returns synchronous fixtures, every queries module is replaced
// with a queryKey-only shape, and the imperative side effects (post,
// upload, draft, emit, cache write) are fed by `jest.fn()`s the tests
// inspect after the fact.

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

const mockPostNewStatus = jest.fn()
const mockUploadMediaBatch = jest.fn()
const mockAppendReply = jest.fn()
const mockEmit = jest.fn()
const mockRouterReplace = jest.fn()
const mockRouterBack = jest.fn()

// Each `useQuery` call inside `useComposer` is keyed — dispatch on the
// queryKey's distinctive segment so tests can stage per-call fixtures
// without an order-dependent mockReturnValueOnce chain. Tests can mutate
// `mockQueryFixtures` between renders. (jest.mock factories may only
// reference out-of-scope variables when the binding name starts with
// `mock`; the prefix is required by the hoisting allow-list.)
const mockQueryFixtures: {
  instance: any
  composeSettings: any
  self: any
  inReplyTo: any
} = {
  instance: undefined,
  composeSettings: undefined,
  self: undefined,
  inReplyTo: undefined,
}

jest.mock('@tanstack/react-query', () => {
  const actual = jest.requireActual('@tanstack/react-query') as typeof import('@tanstack/react-query')
  const useQuery = (opts: { queryKey?: readonly unknown[] }) => {
    const tag = (opts?.queryKey ?? [])[0] as string | undefined
    if (tag === 'instance')
      return {
        data: mockQueryFixtures.instance,
        isPending: mockQueryFixtures.instance === undefined,
        error: null,
      }
    if (tag === 'composeSettings')
      return {
        data: mockQueryFixtures.composeSettings,
        isPending: mockQueryFixtures.composeSettings === undefined,
        error: null,
      }
    if (tag === 'self')
      return { data: mockQueryFixtures.self, isPending: false, error: null }
    if (tag === 'detail')
      return { data: mockQueryFixtures.inReplyTo, isPending: false, error: null }
    return { data: undefined, isPending: false, error: null }
  }
  return {
    ...actual,
    useQuery,
    useQueryClient: () => ({
      invalidateQueries: jest.fn(),
      setQueryData: jest.fn(),
      getQueryData: jest.fn(),
    }),
  }
})

// Stub the queries modules so the hook never reaches `acct()` (which
// requires an MMKV-mocked AccountStore). The shape only needs `queryKey`
// since our `useQuery` mock dispatches on its first segment.
jest.mock('src/lib/queries/camera', () => ({
  cameraQueries: {
    instance: () => ({ queryKey: ['instance'] }),
    composeSettings: () => ({ queryKey: ['composeSettings'] }),
  },
}))
jest.mock('src/lib/queries/accounts', () => ({
  accountQueries: { self: () => ({ queryKey: ['self'] }) },
}))
jest.mock('src/lib/queries/posts', () => ({
  postQueries: { detail: (id: string) => ({ queryKey: ['detail', id] }) },
  appendReplyToCachedThread: (...args: unknown[]) => mockAppendReply(...args),
}))
jest.mock('src/lib/queries/keys', () => ({
  queryKeys: { posts: { byUser: (id: string) => ['byUser', id] } },
}))

jest.mock('expo-router', () => ({
  useRouter: () => ({
    push: jest.fn(),
    back: () => mockRouterBack(),
    replace: (path: string) => mockRouterReplace(path),
  }),
}))

jest.mock('expo-share-intent', () => ({
  useShareIntentContext: () => ({
    hasShareIntent: false,
    shareIntent: { files: [] },
  }),
}))

jest.mock('src/lib/api', () => ({
  postNewStatus: (...args: unknown[]) => mockPostNewStatus(...args),
}))

jest.mock('src/lib/api-context', () => ({ ApiError: class ApiError extends Error {} }))

jest.mock('src/lib/capabilities', () => ({
  useInstanceCapabilities: () => ({
    maxMediaAttachments: 4,
    maxStatusCharacters: 500,
    mediaDescriptionLimit: 1000,
    charactersReservedPerUrl: 23,
  }),
}))

// `src/lib/media` exports we care about: `loadDraft` returns null (no
// draft to resume), `saveDraft` / `clearDraft` are no-ops, `getMediaLimits`
// + `getMediaLimitsForMastodon` return realistic shapes so the
// `mediaRequired` carve-out is exercised against the production resolver.
jest.mock('src/lib/media', () => {
  const { getMediaLimits, getMediaLimitsForMastodon } = jest.requireActual(
    'src/lib/media/limits'
  )
  return {
    captureMedia: jest.fn(),
    pickMedia: jest.fn(),
    processMediaBatch: jest.fn(),
    uploadMediaBatch: (...args: unknown[]) => mockUploadMediaBatch(...args),
    loadDraft: () => null,
    saveDraft: jest.fn(),
    clearDraft: jest.fn(),
    toDraftMedia: (m: unknown[]) => m,
    PermissionDeniedError: class PermissionDeniedError extends Error {
      kind: string
      constructor(kind: string) {
        super(kind)
        this.kind = kind
      }
    },
    getMediaLimits,
    getMediaLimitsForMastodon,
  }
})

jest.mock('src/lib/tapestry/emit', () => ({
  emit: (letter: string) => mockEmit(letter),
}))

jest.mock('src/state/AuthProvider', () => ({
  useAuth: () => ({ user: { type: 'mastodon' } }),
}))

jest.mock('src/lib/visibility', () => {
  // Real coerceVisibility — pure helper, no side effects, the cascade
  // would lose its grip on fork-emitted values without it.
  return jest.requireActual('src/lib/visibility')
})

type UseComposerModule = typeof import('../src/hooks/useComposer')
let useComposer: UseComposerModule['useComposer']

beforeAll(() => {
  useComposer = (require('../src/hooks/useComposer') as UseComposerModule).useComposer
})

beforeEach(() => {
  mockPostNewStatus.mockReset()
  mockUploadMediaBatch.mockReset()
  mockAppendReply.mockReset()
  mockEmit.mockReset()
  mockRouterReplace.mockReset()
  mockRouterBack.mockReset()
  mockQueryFixtures.instance = undefined
  mockQueryFixtures.composeSettings = undefined
  mockQueryFixtures.self = undefined
  mockQueryFixtures.inReplyTo = undefined
})

type ComposerArgs = Parameters<UseComposerModule['useComposer']>[0]
type Captured = ReturnType<UseComposerModule['useComposer']>

let captured: Captured | null = null

function Probe({ args }: { args: ComposerArgs }) {
  captured = useComposer(args)
  return null
}

function defaultArgs(over: Partial<ComposerArgs> = {}): ComposerArgs {
  return {
    quotedStatusId: null,
    inReplyToId: null,
    inReplyTo: null,
    userId: 'u_self',
    lockedAccount: false,
    requireSelfAltText: false,
    mediaEdit: false,
    onError: jest.fn(),
    onPermissionDenied: jest.fn(),
    ...over,
  }
}

function renderHook(args: ComposerArgs): ReactTestRenderer {
  let root: ReactTestRenderer = null as unknown as ReactTestRenderer
  act(() => {
    root = create(React.createElement(Probe, { args }))
  })
  return root
}

describe('useComposer — reply visibility cascade', () => {
  it('lockedAccount → private wins regardless of parent or user default', () => {
    // Leg 4 — load-bearing: a private account should NEVER post a public
    // reply, even to a public parent with `unlistedDefault=true`. This is
    // the strongest invariant in the cascade; the `if (lockedAccount)`
    // branch sits before the `if (hydrated)` short-circuit so it stays
    // active even after the user toggles other state.
    mockQueryFixtures.self = { source: { privacy: 'unlisted' } }
    const root = renderHook(
      defaultArgs({
        lockedAccount: true,
        inReplyToId: 'parent-1',
        inReplyTo: { id: 'parent-1', visibility: 'public' } as any,
      })
    )
    expect(captured?.scope).toBe('private')
    act(() => root.unmount())
  })

  it('parent visibility wins when parent is non-public (don’t widen a private convo)', () => {
    // Leg 1 — the user replies to a private parent on an account whose
    // default is `unlisted`. The cascade must lock to the parent's
    // visibility ('private') so the reply doesn't leak to non-followers.
    // Mastodon-web's `getInitialState` follows the same shape.
    mockQueryFixtures.self = { source: { privacy: 'unlisted' } }
    const root = renderHook(
      defaultArgs({
        inReplyToId: 'parent-2',
        inReplyTo: { id: 'parent-2', visibility: 'private' } as any,
      })
    )
    expect(captured?.scope).toBe('private')
    act(() => root.unmount())
  })

  it('user default wins when parent is public (privacy-conscious user stays unlisted)', () => {
    // Leg 2 — a privacy-conscious user with `unlistedDefault=true`
    // replying to a public parent should NOT have their reply quietly
    // upgraded to public just because the parent was. The user-default
    // leg of the cascade.
    mockQueryFixtures.self = { source: { privacy: 'unlisted' } }
    const root = renderHook(
      defaultArgs({
        inReplyToId: 'parent-3',
        inReplyTo: { id: 'parent-3', visibility: 'public' } as any,
      })
    )
    expect(captured?.scope).toBe('unlisted')
    act(() => root.unmount())
  })

  it('falls back to public when parent is public and no user default is set', () => {
    // Leg 3 — neither the parent nor the user nudges the cascade; the
    // server default ('public' on Mastodon's `getMediaLimitsForMastodon`)
    // wins. Important: do NOT regress to 'unlisted' or 'private' here —
    // a user without a privacy preference shouldn't get their reply
    // narrowed by the cascade's own opinion.
    mockQueryFixtures.self = { source: { privacy: 'public' } }
    const root = renderHook(
      defaultArgs({
        inReplyToId: 'parent-4',
        inReplyTo: { id: 'parent-4', visibility: 'public' } as any,
      })
    )
    expect(captured?.scope).toBe('public')
    act(() => root.unmount())
  })
})

describe('useComposer — reply mediaRequired carve-out', () => {
  it('Pixelfed mediaRequired + reply + caption-only → canPost = true', () => {
    // Pixelfed's image-first ceiling (`mediaRequired=true`) is right for
    // top-level posts but breaks parity with every other client on
    // replies. The carve-out at `useComposer.ts:476-478` adds
    // `|| !!args.inReplyToId` to the `hasContent` derivation; without
    // it, a text-only reply on Pixelfed sees the post button stuck
    // disabled.
    mockQueryFixtures.instance = {
      configuration: { statuses: { max_characters: 500 } },
    }
    mockQueryFixtures.composeSettings = { default_scope: 'public' }
    // Switch the auth mock for the duration of this test so the hook
    // takes the Pixelfed branch (mediaRequired=true).
    const auth = require('src/state/AuthProvider')
    const origUseAuth = auth.useAuth
    auth.useAuth = () => ({ user: { type: 'pixelfed' } })
    try {
      const root = renderHook(
        defaultArgs({ inReplyToId: 'parent-5', inReplyTo: null })
      )
      // Sanity — limits resolved as Pixelfed (mediaRequired=true).
      expect(captured?.limits.mediaRequired).toBe(true)
      // canPost must be true even with no caption + no media because
      // `inReplyToId` is the content trigger.
      expect(captured?.canPost).toBe(true)
      act(() => root.unmount())
    } finally {
      auth.useAuth = origUseAuth
    }
  })

  it('top-level on Pixelfed (no inReplyToId, no media) keeps canPost = false', () => {
    // Regression guard — the carve-out must NOT widen the gate for
    // non-reply paths, or every Pixelfed top-level post would ignore the
    // platform's image-first rule.
    mockQueryFixtures.instance = {
      configuration: { statuses: { max_characters: 500 } },
    }
    mockQueryFixtures.composeSettings = { default_scope: 'public' }
    const auth = require('src/state/AuthProvider')
    const origUseAuth = auth.useAuth
    auth.useAuth = () => ({ user: { type: 'pixelfed' } })
    try {
      const root = renderHook(defaultArgs({}))
      expect(captured?.limits.mediaRequired).toBe(true)
      expect(captured?.canPost).toBe(false)
      act(() => root.unmount())
    } finally {
      auth.useAuth = origUseAuth
    }
  })
})

describe('useComposer — handlePost reply path', () => {
  it('sends in_reply_to_id, calls appendReplyToCachedThread, emits "C", routes back', async () => {
    // The end-to-end reply-success path. Ensures the four side effects
    // the deleted `useThreadReply.postMutation.onSuccess` performed are
    // preserved on the new compose-success branch:
    //   1. params.in_reply_to_id sent on the wire,
    //   2. server-stamped reply lands in the cached thread (peer
    //      Home/Profile rows reflect the bump on next mount),
    //   3. tapestry letter `'C'` emitted (NOT `'P'` — comments are not
    //      new posts; preserves the prior ledger semantic),
    //   4. router.back() returns to the originating screen instead of
    //      replacing to /home (the latter would be jarring on the
    //      reply path).
    mockQueryFixtures.self = { source: { privacy: 'public' } }
    const stampedReply = { id: 'reply-stamped' }
    mockUploadMediaBatch.mockResolvedValue({ ok: true, ids: [] })
    mockPostNewStatus.mockResolvedValue(stampedReply)

    const root = renderHook(
      defaultArgs({
        inReplyToId: 'parent-6',
        inReplyTo: { id: 'parent-6', visibility: 'public' } as any,
      })
    )
    // Type some content so canPost flips (Mastodon path — text-only
    // replies are valid without the carve-out).
    act(() => captured?.setCaption('hi'))
    expect(captured?.canPost).toBe(true)
    await act(async () => {
      await captured?.handlePost()
    })

    expect(mockPostNewStatus).toHaveBeenCalledTimes(1)
    const params = mockPostNewStatus.mock.calls[0][0] as Record<string, unknown>
    expect(params.in_reply_to_id).toBe('parent-6')
    expect(params.status).toBe('hi')
    expect(mockAppendReply).toHaveBeenCalledWith('parent-6', stampedReply)
    expect(mockEmit).toHaveBeenCalledWith('C')
    expect(mockRouterBack).toHaveBeenCalledTimes(1)
    expect(mockRouterReplace).not.toHaveBeenCalled()
    act(() => root.unmount())
  })

  it('top-level post (no inReplyToId) does NOT set in_reply_to_id, emits "P", routes to home', async () => {
    // Regression guard for the param-shape: the reply branch must not
    // bleed into the top-level path. A stray `in_reply_to_id: null` or
    // empty string in params would have the server reject the post.
    mockQueryFixtures.self = { source: { privacy: 'public' } }
    const stamped = { id: 'top-stamped' }
    mockUploadMediaBatch.mockResolvedValue({ ok: true, ids: [] })
    mockPostNewStatus.mockResolvedValue(stamped)

    const root = renderHook(defaultArgs({}))
    act(() => captured?.setCaption('first post'))
    await act(async () => {
      await captured?.handlePost()
    })

    const params = mockPostNewStatus.mock.calls[0][0] as Record<string, unknown>
    expect('in_reply_to_id' in params).toBe(false)
    expect(mockAppendReply).not.toHaveBeenCalled()
    expect(mockEmit).toHaveBeenCalledWith('P')
    expect(mockRouterReplace).toHaveBeenCalledWith('/(auth)/(tabs)/home')
    expect(mockRouterBack).not.toHaveBeenCalled()
    act(() => root.unmount())
  })
})
