// `appendReplyToCachedThread` cache-write tests. The helper plants a
// server-stamped reply into every cached thread containing the parent
// (or whose anchor *is* the parent), bumps the parent's `reply_count`
// inline on each, and bumps the `posts.byId(parentId)` slice so peer
// surfaces (Home / Profile / Search FeedPost rows) reflect the new
// count without a refetch.
//
// Sibling of `patchAnchorReplyCount` — same singleton-`queryClient`
// import, same "imperative cache write from a non-hook caller" shape
// the test scaffolding mirrors. The bug-fix path it underwrites: when
// a reply is posted from the modal compose, the originating screen's
// thread cache must contain the new row before `router.back()` lands,
// or the user sees a flicker / "no posts" state until the next
// refetch. Tested across the four common cache shapes per plan P7:
//   1. parent in descendants of one thread,
//   2. parent in ancestors of one thread,
//   3. parent is itself the anchor of a thread,
//   4. parent in multiple threads simultaneously.

import { createMmkvInstances } from './_helpers/mockMmkv'
import { createSecureStoreMock } from './_helpers/mockSecureStore'

const mockMmkv = createMmkvInstances()
const mockSecure = createSecureStoreMock()

jest.mock('react-native-mmkv', () => ({
  createMMKV: ({
    id,
    compareBeforeSet,
  }: {
    id: string
    compareBeforeSet?: boolean
  }) => mockMmkv.getOrCreate(id, { compareBeforeSet }),
}))

jest.mock('expo-secure-store', () => ({
  getItem: (key: string) => mockSecure.getItem(key),
  setItem: (key: string, value: string) => mockSecure.setItem(key, value),
  deleteItemAsync: (key: string) => mockSecure.deleteItemAsync(key),
}))

// Lazy require: `appendReplyToCachedThread` imports `./client` (which
// constructs the singleton QueryClient with the MMKV-backed persister) and
// `queryKeys` (which calls `acct()` on every key construction). Both must
// run AFTER the MMKV / SecureStore mocks are registered. Same shape
// `patchAnchorReplyCount.ts` uses — `jest.isolateModules` per test so the
// singleton QueryClient's cache doesn't bleed across cases.
type AccountStoreModule = typeof import('../src/state/accountStore')
type PostsModule = typeof import('../src/lib/queries/posts')
type ClientModule = typeof import('../src/lib/queries/client')
type KeysModule = typeof import('../src/lib/queries/keys')
type Status = import('../src/lib/api-types').Status

let AccountStore: AccountStoreModule['AccountStore']
let appendReplyToCachedThread: PostsModule['appendReplyToCachedThread']
let queryClient: ClientModule['queryClient']
let queryKeys: KeysModule['queryKeys']

beforeEach(() => {
  mockMmkv.reset()
  mockSecure.reset()
  jest.isolateModules(() => {
    AccountStore = (
      require('../src/state/accountStore') as AccountStoreModule
    ).AccountStore
    appendReplyToCachedThread = (
      require('../src/lib/queries/posts') as PostsModule
    ).appendReplyToCachedThread
    queryClient = (require('../src/lib/queries/client') as ClientModule).queryClient
    queryKeys = (require('../src/lib/queries/keys') as KeysModule).queryKeys
  })

  // queryKeys.posts.* require an active account — every key is implicitly
  // per-account scoped. Set one up so the helper can reach its slots.
  const a = AccountStore.upsert({
    type: 'pixelfed',
    instance: 'test.example',
    userId: 'u_a',
    username: 'tester',
    acct: 'tester@test.example',
    displayName: 'Tester',
    avatarUrl: null,
  })
  AccountStore.setActive(a.accountId)
})

function statusFixture(overrides: Partial<Status>): Status {
  // Helper only reads `id` / `reply_count` / `replies_count` — pad the rest
  // with the type assertion rather than dragging in 80 fields of MastoAPI
  // noise.
  return overrides as unknown as Status
}

type ThreadCtx = { ancestors: Status[]; descendants: Status[] }

describe('appendReplyToCachedThread', () => {
  it('appends the reply to descendants and bumps the parent inline (parent IN descendants)', () => {
    // The most common case — user is reading a thread, taps Reply on a
    // sub-comment, posts. The parent is a descendant of the anchor; the
    // helper must (a) add the new reply to descendants in-order, (b)
    // bump the parent's `reply_count` from 0 → 1 in place.
    const anchorId = 'anchor-1'
    const parentId = 'comment-3'
    const before: ThreadCtx = {
      ancestors: [],
      descendants: [
        statusFixture({ id: 'comment-1' }),
        statusFixture({ id: 'comment-2' }),
        statusFixture({ id: parentId, reply_count: 0 }),
      ],
    }
    queryClient.setQueryData(queryKeys.posts.thread(anchorId), before)

    const reply = statusFixture({ id: 'reply-1' })
    appendReplyToCachedThread(parentId, reply)

    const after = queryClient.getQueryData<ThreadCtx>(
      queryKeys.posts.thread(anchorId)
    )
    expect(after).toBeDefined()
    expect(after?.descendants).toHaveLength(4)
    // Reply lands at the tail (preorder append — no sort, the freshness
    // stamp is the route-back trigger).
    expect(after?.descendants.at(-1)?.id).toBe('reply-1')
    // Parent row's `reply_count` bumped in place; sibling rows untouched.
    const parent = after?.descendants.find((s) => s.id === parentId)
    expect((parent as any)?.reply_count).toBe(1)
    expect(after?.descendants[0].id).toBe('comment-1')
    expect(after?.descendants[1].id).toBe('comment-2')
  })

  it('bumps the parent inline when it lives in ancestors (still appends to descendants)', () => {
    // Edge case the user can hit from the post-screen's `<AncestorRow>`:
    // they're viewing a deep thread, scroll up, tap Reply on an
    // *ancestor*. The parent isn't in descendants — it's above the
    // anchor. The helper must walk ancestors too and bump there, while
    // still appending the new reply to descendants (the new row is now
    // a peer of the anchor under the ancestor).
    const anchorId = 'anchor-2'
    const ancestorId = 'ancestor-1'
    const before: ThreadCtx = {
      ancestors: [statusFixture({ id: ancestorId, reply_count: 5 })],
      descendants: [statusFixture({ id: 'desc-1' })],
    }
    queryClient.setQueryData(queryKeys.posts.thread(anchorId), before)

    const reply = statusFixture({ id: 'reply-2' })
    appendReplyToCachedThread(ancestorId, reply)

    const after = queryClient.getQueryData<ThreadCtx>(
      queryKeys.posts.thread(anchorId)
    )
    const bumpedAncestor = after?.ancestors[0]
    expect((bumpedAncestor as any)?.reply_count).toBe(6)
    expect(after?.descendants).toHaveLength(2)
    expect(after?.descendants.at(-1)?.id).toBe('reply-2')
  })

  it('appends to descendants when the parent IS the anchor (queryKey ends with parentId)', () => {
    // Distinct shape from "parent in descendants": neither array contains
    // the anchor itself (the anchor lives in `posts.byId`, not in the
    // thread context payload). The helper detects this by checking the
    // queryKey's last segment — `[acct(), 'thread', parentId]`. Without
    // this branch, replying directly to the anchor (the post-screen's
    // anchor `message-circle` icon) would silently drop the reply.
    const anchorId = 'anchor-3'
    const before: ThreadCtx = {
      ancestors: [],
      descendants: [statusFixture({ id: 'existing' })],
    }
    queryClient.setQueryData(queryKeys.posts.thread(anchorId), before)

    const reply = statusFixture({ id: 'reply-3' })
    appendReplyToCachedThread(anchorId, reply)

    const after = queryClient.getQueryData<ThreadCtx>(
      queryKeys.posts.thread(anchorId)
    )
    expect(after?.descendants).toHaveLength(2)
    expect(after?.descendants.at(-1)?.id).toBe('reply-3')
  })

  it('updates every cached thread when the parent appears in multiple', () => {
    // The user navigated through several anchors in the same session
    // (deep-link → tapped through to a sub-thread → backed out). Both
    // thread caches retain the parent. A reply should land in every
    // cached thread that contains the parent so peer screens reflect
    // the bump on next mount.
    const before1: ThreadCtx = {
      ancestors: [],
      descendants: [statusFixture({ id: 'p', reply_count: 1 })],
    }
    const before2: ThreadCtx = {
      ancestors: [statusFixture({ id: 'p', reply_count: 1 })],
      descendants: [statusFixture({ id: 'other-anchor-desc' })],
    }
    queryClient.setQueryData(queryKeys.posts.thread('thread-A'), before1)
    queryClient.setQueryData(queryKeys.posts.thread('thread-B'), before2)

    const reply = statusFixture({ id: 'reply-4' })
    appendReplyToCachedThread('p', reply)

    const a = queryClient.getQueryData<ThreadCtx>(queryKeys.posts.thread('thread-A'))
    const b = queryClient.getQueryData<ThreadCtx>(queryKeys.posts.thread('thread-B'))
    // Thread A: parent in descendants → bumped + reply appended.
    expect((a?.descendants[0] as any)?.reply_count).toBe(2)
    expect(a?.descendants.at(-1)?.id).toBe('reply-4')
    // Thread B: parent in ancestors → bumped there, reply still appended
    // to descendants (the new row is a peer of B's anchor under the
    // shared parent).
    expect((b?.ancestors[0] as any)?.reply_count).toBe(2)
    expect(b?.descendants.at(-1)?.id).toBe('reply-4')
  })

  it('bumps the byId cache for the parent on both reply_count and replies_count', () => {
    // Peer surfaces (Home / Profile / Search FeedPost rows) read from
    // `posts.byId(parentId)`, which lives outside the thread context.
    // The helper must patch *both* wire fields — Pixelfed reads
    // `reply_count`, Mastodon reads `replies_count` — so the count is
    // consistent regardless of which renderer hits the row first.
    const parentId = 'feed-row-1'
    queryClient.setQueryData(
      queryKeys.posts.byId(parentId),
      statusFixture({ id: parentId, reply_count: 4, favourited: true })
    )

    const reply = statusFixture({ id: 'reply-5' })
    appendReplyToCachedThread(parentId, reply)

    const after = queryClient.getQueryData<Status>(queryKeys.posts.byId(parentId))
    expect((after as any)?.reply_count).toBe(5)
    expect((after as any)?.replies_count).toBe(5)
    // Spread guard — unrelated fields preserved.
    expect(after?.favourited).toBe(true)
  })

  it('is a no-op when no thread contains the parent and no byId entry exists', () => {
    // First-time reply path with cold caches — the helper should accept
    // the empty-cache shape rather than throw. Common in deep-link
    // scenarios where the user lands on a notification, taps the
    // referenced post, and hits Reply before any timeline has populated.
    expect(() => appendReplyToCachedThread('nowhere', statusFixture({ id: 'r' })))
      .not.toThrow()
    // Sanity: no entry was synthesised under byId either — the helper's
    // updater short-circuits on `prev` being undefined.
    expect(
      queryClient.getQueryData<Status>(queryKeys.posts.byId('nowhere'))
    ).toBeUndefined()
  })
})
