// `updateCredentials` wire-shape contract.
//
// The legacy `updateAvatar` helper wrapped the file in `{ avatar: { payload:
// file } }`, which broke RN FormData's file-detection (it recognises a flat
// `{ uri, name, type }` object) and serialised the value as `[object
// Object]`. Pixelfed's controller then 422-rejected the upload, and the bare
// `await … .then()` call sites in `profile.tsx` and `verifyEmail.tsx`
// swallowed the throw — the user saw "nothing happened, no error".
//
// This test pins the new shape:
//
//   1. The avatar field is appended as the flat `{ uri, name, type }`
//      object — the wrapping `{ payload }` is gone.
//   2. Booleans are stringified to `'true'` / `'false'`.
//   3. Strings ride through unchanged.
//   4. `undefined` and `null` values are skipped (so callers can build
//      partial inputs and unset fields aren't sent over).
//   5. The bracket-notation `source[privacy]` key reaches the wire
//      verbatim — Mastodon's nested-params parser depends on it.

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

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

// `updateCredentials` uses the RN-flavoured `FormData` polyfill (the
// `{ uri, name, type }` file shape is detected at multipart serialisation
// time). Jest's jsdom-backed `FormData` instead stringifies non-Blob values
// to `"[object Object]"`, which would mask the shape we're trying to pin.
// Substitute a minimal RN-shaped polyfill so `_parts` reflects the
// raw `(key, value)` tuples our production code appends.
class FakeFormData {
  _parts: Array<[string, unknown]> = []
  append(key: string, value: unknown) {
    this._parts.push([key, value])
  }
}
;(globalThis as unknown as { FormData: typeof FakeFormData }).FormData =
  FakeFormData as unknown as typeof FakeFormData

const mockPostForm = jest.fn<Promise<unknown>, [string, FakeFormData]>(async () => ({}))

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

jest.mock('expo-secure-store', () => ({
  getItem: (k: string) => mockSecure.getItem(k),
  setItem: (k: string, v: string) => mockSecure.setItem(k, v),
  deleteItemAsync: (k: string) => mockSecure.deleteItemAsync(k),
  WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: 0,
}))

jest.mock('src/lib/api-context', () => ({
  ContextForActiveAccount: () => ({
    postForm: (path: string, form: FormData) =>
      mockPostForm(path, form as unknown as FakeFormData),
    patch: jest.fn(),
    get: jest.fn(),
    post: jest.fn(),
  }),
}))

type SharedModule = typeof import('../src/lib/api/shared')
let updateCredentials: SharedModule['updateCredentials']

beforeAll(() => {
  updateCredentials = (require('../src/lib/api/shared') as SharedModule)
    .updateCredentials
})

beforeEach(() => {
  mockMmkv.reset()
  mockSecure.reset()
  mockPostForm.mockReset().mockResolvedValue({})
})

function pick(form: FakeFormData, key: string): unknown {
  for (const [k, v] of form._parts) if (k === key) return v
  return undefined
}

describe('updateCredentials wire shape', () => {
  it('sends the avatar as a flat file object, not wrapped in { payload }', async () => {
    const file = { uri: 'file:///tmp/a.jpg', name: 'a.jpg', type: 'image/jpeg' }
    await updateCredentials({ avatar: file })

    expect(mockPostForm).toHaveBeenCalledTimes(1)
    const [path, form] = mockPostForm.mock.calls[0]
    expect(path).toBe('api/v1/accounts/update_credentials')
    const avatar = pick(form, 'avatar')
    // The whole point: avatar is the original file object (or a structural
    // copy of it) — never `{ payload: file }`.
    expect(avatar).toBeDefined()
    expect(avatar).not.toEqual({ payload: file })
    expect(avatar).toMatchObject({
      uri: 'file:///tmp/a.jpg',
      name: 'a.jpg',
      type: 'image/jpeg',
    })
  })

  it('stringifies booleans to "true" / "false"', async () => {
    await updateCredentials({ locked: true, public_dm: false })
    const form = mockPostForm.mock.calls[0][1]
    expect(pick(form, 'locked')).toBe('true')
    expect(pick(form, 'public_dm')).toBe('false')
  })

  it('passes string fields through unchanged', async () => {
    await updateCredentials({
      display_name: 'Alice',
      note: 'hi',
      website: 'https://example.com',
    })
    const form = mockPostForm.mock.calls[0][1]
    expect(pick(form, 'display_name')).toBe('Alice')
    expect(pick(form, 'note')).toBe('hi')
    expect(pick(form, 'website')).toBe('https://example.com')
  })

  it('skips undefined and null fields', async () => {
    await updateCredentials({
      display_name: 'Alice',
      note: undefined,
      website: null as unknown as undefined,
    })
    const form = mockPostForm.mock.calls[0][1]
    expect(pick(form, 'display_name')).toBe('Alice')
    expect(pick(form, 'note')).toBeUndefined()
    expect(pick(form, 'website')).toBeUndefined()
  })

  it('reaches the wire with the bracket-notation Mastodon visibility key', async () => {
    await updateCredentials({ 'source[privacy]': 'unlisted' })
    const form = mockPostForm.mock.calls[0][1]
    expect(pick(form, 'source[privacy]')).toBe('unlisted')
  })

  it('combines avatar + text fields in a single multipart request', async () => {
    // Pre-fix, this required two calls (`updateAvatar` + `updateCredentials`).
    // The collapsed shape lets a fresh-signup welcome step send avatar +
    // bio in one round-trip — which `verifyEmail.tsx` now does.
    const file = { uri: 'file:///tmp/a.jpg', name: 'a.jpg', type: 'image/jpeg' }
    await updateCredentials({ avatar: file, note: 'about me' })
    expect(mockPostForm).toHaveBeenCalledTimes(1)
    const form = mockPostForm.mock.calls[0][1]
    expect(pick(form, 'avatar')).toMatchObject(file)
    expect(pick(form, 'note')).toBe('about me')
  })
})
