// `updatePostInCache` cache-walk tests. The helper is the single-source
// optimistic-update path for like / bookmark / share / RSVP — every
// surface that wants to bump a status counter goes through here. The
// load-bearing invariants pinned below:
//
//  1. Top-level status match — direct posts (Pixelfed home, Mastodon
//     non-boost rows) get the updater applied in-place inside their
//     paginated `{data: Status[]}` page.
//  2. Reblog-wrapper match (the Mastodon regression — see commit log) —
//     a boost in the feed is an outer `Status` whose own `id` is the
//     wrapper-id with the real content under `reblog`. Like / share /
//     bookmark fire with the inner-status id; the walker has to descend
//     into `reblog` or the count never bumps on Mastodon's
//     boost-dominated home timeline.
//  3. Referential identity is preserved when nothing matched — pages,
//     wrappers, and inner statuses stay `===` to their old refs.
//     FlashList reuses cells aggressively on identity and a churn write
//     marks every row dirty for no reason.
//  4. Thread cache shape (`{ancestors, descendants}`) is walked the same
//     way as paginated `{pages: [{data}]}` shape so a thread containing
//     a boost still receives the optimistic patch.

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),
}))

// `updatePostInCache` takes a `QueryClient` parameter — we construct a
// fresh one per test instead of importing the singleton at
// `src/lib/queries/client`. The singleton's module graph transitively
// imports `react-native-watch-connectivity` (via `api-context.ts` →
// `watch/handoff.ts`), which throws under `jest-expo` because the native
// `WatchConnectivity` TurboModule is not registered in the JS env. Using
// a fresh client also avoids cache bleed between tests without
// `jest.isolateModules`.
import { QueryClient } from '@tanstack/react-query'

// Lazy require — `queryKeys` and `updatePostInCache` both call `acct()`
// (the active-account-id getter) at runtime, which reads MMKV. The
// modules must load AFTER the MMKV / SecureStore mocks are registered
// and AFTER an active account is upserted.
type AccountStoreModule = typeof import('../src/state/accountStore')
type KeysModule = typeof import('../src/lib/queries/keys')
type UpdateModule = typeof import('../src/hooks/mutations/updatePostInCache')
type Status = import('../src/lib/api-types').Status

let AccountStore: AccountStoreModule['AccountStore']
let queryClient: QueryClient
let queryKeys: KeysModule['queryKeys']
let updatePostInCache: UpdateModule['updatePostInCache']

beforeEach(() => {
  mockMmkv.reset()
  mockSecure.reset()
  jest.isolateModules(() => {
    AccountStore = (require('../src/state/accountStore') as AccountStoreModule)
      .AccountStore
    queryKeys = (require('../src/lib/queries/keys') as KeysModule).queryKeys
    updatePostInCache = (
      require('../src/hooks/mutations/updatePostInCache') as UpdateModule
    ).updatePostInCache
  })

  const a = AccountStore.upsert({
    type: 'mastodon',
    instance: 'mastodon.example',
    userId: 'u_a',
    username: 'tester',
    acct: 'tester@mastodon.example',
    displayName: 'Tester',
    avatarUrl: null,
  })
  AccountStore.setActive(a.accountId)

  queryClient = new QueryClient()
})

afterEach(() => {
  queryClient.clear()
})

function status(overrides: Partial<Status>): Status {
  return overrides as unknown as Status
}

// Production code only reads `id`, `favourites_count`, `favourited`,
// `bookmarked`, `reblog`, … off these — no need to drag the 80-field
// MastoAPI shape into every fixture.
const liker = (s: Status): Status => ({
  ...s,
  favourited: true,
  favourites_count: (s.favourites_count ?? 0) + 1,
})

describe('updatePostInCache', () => {
  it('updates a direct (non-boost) post in a paginated home feed', () => {
    const direct = status({ id: 'p1', favourited: false, favourites_count: 0 })
    const other = status({ id: 'p2', favourited: false, favourites_count: 5 })
    queryClient.setQueryData(queryKeys.posts.homeFeed(), {
      pages: [{ data: [direct, other] }],
      pageParams: [false],
    })

    updatePostInCache(queryClient, 'p1', liker)

    const after = queryClient.getQueryData<{ pages: { data: Status[] }[] }>(
      queryKeys.posts.homeFeed()
    )
    expect(after?.pages[0].data[0]).toEqual(
      expect.objectContaining({ id: 'p1', favourited: true, favourites_count: 1 })
    )
    // Untouched neighbour stays `===` — FlashList depends on this.
    expect(after?.pages[0].data[1]).toBe(other)
  })

  it('descends into the boost wrapper — the Mastodon 0→1 regression', () => {
    // Reproduces the bug the user hit: on a Mastodon home feed full of
    // boosts, tapping like on a row leaves the count visually stuck at 0
    // because the wrapper's own id never matches the inner-status id the
    // mutation fires with.
    const inner = status({ id: 'p_inner', favourited: false, favourites_count: 0 })
    const wrapper = status({ id: 'p_wrapper', reblog: inner })
    queryClient.setQueryData(queryKeys.posts.homeFeed(), {
      pages: [{ data: [wrapper] }],
      pageParams: [false],
    })

    updatePostInCache(queryClient, 'p_inner', liker)

    const after = queryClient.getQueryData<{ pages: { data: Status[] }[] }>(
      queryKeys.posts.homeFeed()
    )
    const updated = after?.pages[0].data[0]
    // The outer wrapper id is unchanged — we only patched the nested reblog.
    expect(updated?.id).toBe('p_wrapper')
    expect(updated?.reblog).toEqual(
      expect.objectContaining({
        id: 'p_inner',
        favourited: true,
        favourites_count: 1,
      })
    )
  })

  it('preserves page + wrapper identity when nothing matched (no churn)', () => {
    // FlashList / React.memo bail on `===` for unchanged subtrees. A churn
    // write would mark every row dirty and force every cell to re-render.
    const unrelated = status({ id: 'p_unrelated', favourites_count: 0 })
    const wrapperUnrelated = status({
      id: 'wrap_unrelated',
      reblog: status({ id: 'inner_unrelated', favourites_count: 4 }),
    })
    const page = { data: [unrelated, wrapperUnrelated] }
    queryClient.setQueryData(queryKeys.posts.homeFeed(), {
      pages: [page],
      pageParams: [false],
    })

    updatePostInCache(queryClient, 'nope', liker)

    const after = queryClient.getQueryData<{ pages: { data: Status[] }[] }>(
      queryKeys.posts.homeFeed()
    )
    // Page object identity preserved.
    expect(after?.pages[0]).toBe(page)
  })

  it('also updates the byId single-post cache when present', () => {
    const detail = status({ id: 'p1', favourited: false, favourites_count: 0 })
    queryClient.setQueryData(queryKeys.posts.byId('p1'), detail)

    updatePostInCache(queryClient, 'p1', liker)

    const after = queryClient.getQueryData<Status>(queryKeys.posts.byId('p1'))
    expect(after).toEqual(
      expect.objectContaining({ favourited: true, favourites_count: 1 })
    )
  })

  it('reaches both home + network feeds in a single call (shared scope)', () => {
    // Boost can show up in network/public feed too. One mutation must
    // patch every feed cache that holds the row, or the user sees a
    // stale count on whichever screen they navigate to next.
    const inner = status({ id: 'shared', favourited: false, favourites_count: 0 })
    const wrapper = status({ id: 'shared_wrap', reblog: inner })
    queryClient.setQueryData(queryKeys.posts.homeFeed(), {
      pages: [{ data: [wrapper] }],
      pageParams: [false],
    })
    queryClient.setQueryData(queryKeys.posts.networkFeed('all'), {
      pages: [{ data: [wrapper] }],
      pageParams: [false],
    })

    updatePostInCache(queryClient, 'shared', liker)

    const home = queryClient.getQueryData<{ pages: { data: Status[] }[] }>(
      queryKeys.posts.homeFeed()
    )
    const net = queryClient.getQueryData<{ pages: { data: Status[] }[] }>(
      queryKeys.posts.networkFeed('all')
    )
    expect(home?.pages[0].data[0].reblog?.favourites_count).toBe(1)
    expect(net?.pages[0].data[0].reblog?.favourites_count).toBe(1)
  })

  it('walks thread ancestors + descendants and preserves identity on no-match rows', () => {
    const anchor = status({ id: 'anchor', favourited: false, favourites_count: 0 })
    const ancestor = status({ id: 'ancestor', favourites_count: 10 })
    const sibling = status({ id: 'sibling', favourites_count: 2 })
    queryClient.setQueryData(queryKeys.posts.thread('anchor'), {
      ancestors: [ancestor],
      descendants: [anchor, sibling],
    })

    updatePostInCache(queryClient, 'anchor', liker)

    const after = queryClient.getQueryData<{
      ancestors: Status[]
      descendants: Status[]
    }>(queryKeys.posts.thread('anchor'))
    expect(after?.descendants[0]).toEqual(
      expect.objectContaining({ id: 'anchor', favourites_count: 1 })
    )
    // Untouched rows keep their reference — no spurious re-renders down
    // the row tree on a thread refresh.
    expect(after?.ancestors[0]).toBe(ancestor)
    expect(after?.descendants[1]).toBe(sibling)
  })

  it('walks a boost wrapper sitting inside a thread descendant subtree', () => {
    // Edge case — thread descendants typically don't carry boosts but the
    // walker shouldn't make that assumption. If a thread row ever contains
    // a reblog wrapper, the optimistic update must reach the inner status
    // the same way it does on a feed page.
    const inner = status({ id: 'descend_inner', favourites_count: 0 })
    const wrapper = status({ id: 'descend_wrap', reblog: inner })
    queryClient.setQueryData(queryKeys.posts.thread('root'), {
      ancestors: [],
      descendants: [wrapper],
    })

    updatePostInCache(queryClient, 'descend_inner', liker)

    const after = queryClient.getQueryData<{ descendants: Status[] }>(
      queryKeys.posts.thread('root')
    )
    expect(after?.descendants[0].reblog?.favourites_count).toBe(1)
  })

  it('walks a flat-array page (profile statuses shape) and matches the reblog wrapper', () => {
    // Mastodon profile timelines include the user's boosts. The page is a
    // plain `Status[]` (not `{data: Status[]}`); the walker has to find
    // the inner status there too. Targets the `Array.isArray(page)` branch
    // in `updatePostInCache`.
    const inner = status({ id: 'profile_inner', favourites_count: 0 })
    const wrapper = status({ id: 'profile_wrap', reblog: inner })
    const profileKey = [...queryKeys.posts.byUser('u_a'), 'all'] as const
    queryClient.setQueryData(profileKey, {
      pages: [[wrapper]],
      pageParams: [undefined],
    })

    updatePostInCache(queryClient, 'profile_inner', liker, [
      queryKeys.posts.byUser('u_a'),
    ])

    const after = queryClient.getQueryData<{ pages: Status[][] }>(profileKey)
    expect(after?.pages[0][0].reblog?.favourites_count).toBe(1)
    expect(after?.pages[0][0].id).toBe('profile_wrap')
  })
})
