import { renderHook, act, waitFor } from '@testing-library/react'
import { useComposerUploads, type AttachmentUploader } from './useComposerUploads'

function fakeUploader(overrides: Partial<AttachmentUploader> = {}): AttachmentUploader {
  return {
    uploadAttachment: jest.fn(async (_id: string, file: File) => ({
      id: `srv-${file.name}`,
      fileUrl: `https://cdn/${file.name}`,
    })),
    deleteAttachment: jest.fn(async () => undefined),
    ...overrides,
  }
}

function file(name: string, type = 'image/png') {
  return new File(['x'], name, { type })
}

beforeEach(() => {
  if (!('createObjectURL' in URL)) {
    // @ts-expect-error test shim
    URL.createObjectURL = jest.fn(() => 'blob:preview')
  }
  if (!('revokeObjectURL' in URL)) {
    // @ts-expect-error test shim
    URL.revokeObjectURL = jest.fn()
  }
})

describe('useComposerUploads', () => {
  it('uploads immediately when a real documentId exists and reports ready', async () => {
    const uploader = fakeUploader()
    const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))

    act(() => result.current.attachFiles([file('logo.png')]))
    expect(result.current.attachments).toHaveLength(1)

    await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
    expect(uploader.uploadAttachment).toHaveBeenCalledTimes(1)
    expect(uploader.uploadAttachment).toHaveBeenCalledWith('doc-1', expect.any(File), expect.any(Object))
  })

  it('non-image files pass through downscale untouched (still upload)', async () => {
    const uploader = fakeUploader()
    const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
    const pdf = file('notes.pdf', 'application/pdf')
    act(() => result.current.attachFiles([pdf]))
    await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
    // The exact File instance is forwarded (downscale is a no-op on non-images).
    expect((uploader.uploadAttachment as jest.Mock).mock.calls[0][1]).toBe(pdf)
  })

  it('holds files client-side when there is no documentId yet, then uploads once it appears (new-deck case)', async () => {
    const uploader = fakeUploader()
    let docId: string | undefined
    const { result, rerender } = renderHook(({ id }: { id?: string }) => useComposerUploads(uploader, id), {
      initialProps: { id: docId },
    })

    act(() => result.current.attachFiles([file('brand.png')]))
    expect(result.current.attachments).toHaveLength(1)
    expect(uploader.uploadAttachment).not.toHaveBeenCalled()

    docId = 'doc-new'
    rerender({ id: docId })

    await waitFor(() => expect(uploader.uploadAttachment).toHaveBeenCalledTimes(1))
    expect(uploader.uploadAttachment).toHaveBeenCalledWith('doc-new', expect.any(File), expect.any(Object))
    await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
  })

  it('marks a chip as error (with the failure reason) when the upload fails', async () => {
    const uploader = fakeUploader({
      uploadAttachment: jest.fn().mockRejectedValue(new Error('boom')),
    })
    const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
    act(() => result.current.attachFiles([file('bad.png')]))
    await waitFor(() => expect(result.current.attachments[0].status).toBe('error'))
    expect(result.current.attachments[0].error).toBe('boom')
  })

  it('retryAttachment re-uploads a failed chip and reaches ready', async () => {
    const upload = jest
      .fn()
      .mockRejectedValueOnce(new Error('flaky network'))
      .mockResolvedValueOnce({ id: 'srv-retry', fileUrl: 'https://cdn/r.png' })
    const uploader = fakeUploader({ uploadAttachment: upload })
    const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
    act(() => result.current.attachFiles([file('r.png')]))
    await waitFor(() => expect(result.current.attachments[0].status).toBe('error'))
    const chipId = result.current.attachments[0].id

    act(() => result.current.retryAttachment(chipId))
    await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
    expect(result.current.attachments[0].error).toBeUndefined()
    expect(upload).toHaveBeenCalledTimes(2)
  })

  it('removes a pending chip and deletes an uploaded attachment server-side', async () => {
    const uploader = fakeUploader()
    const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
    act(() => result.current.attachFiles([file('a.png')]))
    await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
    const chipId = result.current.attachments[0].id

    act(() => result.current.removeAttachment(chipId))
    expect(result.current.attachments).toHaveLength(0)
    expect(uploader.deleteAttachment).toHaveBeenCalledWith('doc-1', 'srv-a.png')
  })

  it('surfaces uploaded refs via onAttachmentsChange when a chip reaches ready', async () => {
    const uploader = fakeUploader()
    const onAttachmentsChange = jest.fn()
    const { result } = renderHook(() =>
      useComposerUploads(uploader, 'doc-1', { onAttachmentsChange })
    )
    act(() => result.current.attachFiles([file('hero.png')]))
    await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
    await waitFor(() =>
      expect(onAttachmentsChange).toHaveBeenLastCalledWith([
        { id: 'srv-hero.png', fileUrl: 'https://cdn/hero.png', name: 'hero.png' },
      ])
    )
  })

  // --- Regression: prod "dropped images never leave 0" (startsim-0fp3) ---
  // When there is no document path (demo engine / brand-new deck whose document
  // never arrives), a queued file used to surface as 'uploading' at progress 0
  // indefinitely — no honest state, no way to know it's parked, no bound.
  describe('no-document strand (startsim-0fp3)', () => {
    it('surfaces a queued file with an honest non-uploading status, not 0%-uploading-forever', () => {
      const uploader = fakeUploader()
      const { result } = renderHook(() => useComposerUploads(uploader, undefined))
      act(() => result.current.attachFiles([file('parked.png')]))
      expect(result.current.attachments).toHaveLength(1)
      // The user must be able to tell this is parked, not actively uploading.
      expect(uploader.uploadAttachment).not.toHaveBeenCalled()
      expect(result.current.attachments[0].status).toBe('queued')
    })

    it('removes a queued (never-uploaded) chip and revokes its object url', () => {
      const revoke = jest.spyOn(URL, 'revokeObjectURL')
      const uploader = fakeUploader()
      const { result } = renderHook(() => useComposerUploads(uploader, undefined))
      act(() => result.current.attachFiles([file('parked.png')]))
      const chipId = result.current.attachments[0].id

      act(() => result.current.removeAttachment(chipId))
      expect(result.current.attachments).toHaveLength(0)
      expect(revoke).toHaveBeenCalled()
      // Never uploaded → no server-side delete attempted.
      expect(uploader.deleteAttachment).not.toHaveBeenCalled()
      revoke.mockRestore()
    })
  })

  describe('clear() cleanup', () => {
    it('empties all chips and revokes every object url', async () => {
      const revoke = jest.spyOn(URL, 'revokeObjectURL')
      const uploader = fakeUploader()
      const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
      act(() => result.current.attachFiles([file('a.png'), file('b.png')]))
      await waitFor(() => expect(result.current.attachments).toHaveLength(2))

      act(() => result.current.clear())
      expect(result.current.attachments).toHaveLength(0)
      expect(revoke).toHaveBeenCalledTimes(2)
      revoke.mockRestore()
    })
  })
})
