import { useLayoutEffect } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useComposerAttachments } from '@/pages/Chat/use-composer-attachments';
import type {
  ComposerAttachmentDependencies,
} from '@/pages/Chat/composer-attachments';
import type {
  ProcessedImageAttachment,
} from '@/lib/image-attachment-compression';

function createDeferred<T>() {
  let resolve!: (value: T) => void;
  let reject!: (reason?: unknown) => void;
  const promise = new Promise<T>((promiseResolve, promiseReject) => {
    resolve = promiseResolve;
    reject = promiseReject;
  });
  return { promise, reject, resolve };
}

function createDependencies(): ComposerAttachmentDependencies {
  let nextId = 0;
  let nextUrl = 0;
  return {
    objectUrls: {
      createObjectURL: vi.fn(() => `blob:hook-${++nextUrl}`),
      revokeObjectURL: vi.fn(),
    },
    idFactory: vi.fn(() => `attachment-${++nextId}`),
  };
}

function unchanged(file: File): ProcessedImageAttachment {
  return {
    status: 'unchanged',
    original: {
      blob: file,
      mimeType: file.type,
      bytes: file.size,
      width: 1280,
      height: 720,
    },
    defaultVariant: 'original',
  };
}

describe('useComposerAttachments', () => {
  it('runs at most one image processing job at a time', async () => {
    const dependencies = createDependencies();
    const first = createDeferred<ProcessedImageAttachment>();
    const second = createDeferred<ProcessedImageAttachment>();
    const pending = [first, second];
    let active = 0;
    let maximumActive = 0;
    const processImage = vi.fn((file: File, signal: AbortSignal) => {
      expect(signal.aborted).toBe(false);
      active += 1;
      maximumActive = Math.max(maximumActive, active);
      return pending[processImage.mock.calls.length - 1].promise.finally(() => {
        active -= 1;
      });
    });
    const { result } = renderHook(() => useComposerAttachments({
      selectedSessionId: 'ses_1',
      dependencies,
      processImage,
    }));
    const firstFile = new File(['first'], 'first.png', { type: 'image/png' });
    const secondFile = new File(['second'], 'second.png', { type: 'image/png' });

    act(() => result.current.addFiles([firstFile, secondFile]));

    expect(processImage).toHaveBeenCalledTimes(1);
    expect(maximumActive).toBe(1);

    await act(async () => {
      first.resolve(unchanged(firstFile));
      await first.promise;
    });
    await waitFor(() => expect(processImage).toHaveBeenCalledTimes(2));
    expect(maximumActive).toBe(1);

    await act(async () => {
      second.resolve(unchanged(secondFile));
      await second.promise;
    });
  });

  it('removes a queued image without ever starting its processing job', async () => {
    const dependencies = createDependencies();
    const first = createDeferred<ProcessedImageAttachment>();
    const processImage = vi.fn(() => first.promise);
    const { result } = renderHook(() => useComposerAttachments({
      selectedSessionId: 'ses_1',
      dependencies,
      processImage,
    }));
    const firstFile = new File(['first'], 'first.png', { type: 'image/png' });
    const secondFile = new File(['second'], 'second.png', { type: 'image/png' });

    act(() => result.current.addFiles([firstFile, secondFile]));
    const queuedId = result.current.attachments[1].id;
    act(() => result.current.removeAttachment(queuedId));

    await act(async () => {
      first.resolve(unchanged(firstFile));
      await first.promise;
    });

    expect(processImage).toHaveBeenCalledTimes(1);
    expect(processImage).toHaveBeenCalledWith(firstFile, expect.any(AbortSignal));
  });

  it('aborts an active image on removal and ignores its late compressed result', async () => {
    const dependencies = createDependencies();
    const deferred = createDeferred<ProcessedImageAttachment>();
    let signal: AbortSignal | undefined;
    const processImage = vi.fn((_file: File, nextSignal: AbortSignal) => {
      signal = nextSignal;
      return deferred.promise;
    });
    const { result } = renderHook(() => useComposerAttachments({
      selectedSessionId: 'ses_1',
      dependencies,
      processImage,
    }));
    const file = new File(['original'], 'large.png', { type: 'image/png' });

    act(() => result.current.addFiles([file]));
    const id = result.current.attachments[0].id;
    act(() => result.current.removeAttachment(id));

    expect(signal?.aborted).toBe(true);
    await act(async () => {
      deferred.resolve({
        status: 'compressed',
        original: {
          blob: file,
          mimeType: 'image/png',
          bytes: file.size,
          width: 3000,
          height: 2000,
        },
        compressed: {
          blob: new Blob(['compressed'], { type: 'image/png' }),
          mimeType: 'image/png',
          bytes: 10,
          width: 1773,
          height: 1182,
        },
        defaultVariant: 'compressed',
      });
      await deferred.promise;
    });
    expect(result.current.attachments).toEqual([]);
    expect(dependencies.objectUrls.createObjectURL).toHaveBeenCalledTimes(1);
  });

  it('aborts active work and drops queued work on a session switch', async () => {
    const dependencies = createDependencies();
    const active = createDeferred<ProcessedImageAttachment>();
    let signal: AbortSignal | undefined;
    const processImage = vi.fn((_file: File, nextSignal: AbortSignal) => {
      signal = nextSignal;
      return active.promise;
    });
    const { result, rerender } = renderHook(
      ({ sessionId }) => useComposerAttachments({
        selectedSessionId: sessionId,
        dependencies,
        processImage,
      }),
      { initialProps: { sessionId: 'ses_1' } },
    );
    const firstFile = new File(['first'], 'first.png', { type: 'image/png' });

    act(() => result.current.addFiles([
      firstFile,
      new File(['second'], 'second.png', { type: 'image/png' }),
    ]));
    rerender({ sessionId: 'ses_2' });

    expect(signal?.aborted).toBe(true);
    await act(async () => {
      active.resolve(unchanged(firstFile));
      await active.promise;
    });
    expect(processImage).toHaveBeenCalledTimes(1);
    expect(result.current.attachments).toEqual([]);
  });

  it('deduplicates before starting a job and revokes the rejected preview', () => {
    const dependencies = createDependencies();
    const processImage = vi.fn(async (file: File) => unchanged(file));
    const { result } = renderHook(() => useComposerAttachments({
      selectedSessionId: 'ses_1',
      dependencies,
      processImage,
    }));
    const file = new File(['same'], 'same.png', { type: 'image/png' });

    act(() => result.current.addFiles([file, file]));

    expect(result.current.attachments).toHaveLength(1);
    expect(processImage).toHaveBeenCalledTimes(1);
    expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledTimes(1);
    expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledWith('blob:hook-2');
  });

  it('discards a late job before creating a compressed preview', async () => {
    const dependencies = createDependencies();
    const deferred = createDeferred<ProcessedImageAttachment>();
    const processImage = vi.fn(() => deferred.promise);
    const { result } = renderHook(() => useComposerAttachments({
      selectedSessionId: 'ses_1',
      dependencies,
      processImage,
    }));
    const file = new File(['original'], 'large.png', { type: 'image/png' });

    act(() => result.current.addFiles([file]));
    const id = result.current.attachments[0].id;
    act(() => result.current.removeAttachment(id));
    expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledWith('blob:hook-1');

    await act(async () => {
      deferred.resolve({
        status: 'compressed',
        original: {
          blob: file,
          mimeType: 'image/png',
          bytes: file.size,
          width: 3000,
          height: 2000,
        },
        compressed: {
          blob: new Blob(['compressed'], { type: 'image/png' }),
          mimeType: 'image/png',
          bytes: 10,
          width: 1773,
          height: 1182,
        },
        defaultVariant: 'compressed',
      });
      await deferred.promise;
    });

    expect(result.current.attachments).toEqual([]);
    expect(dependencies.objectUrls.createObjectURL).toHaveBeenCalledTimes(1);
  });

  it('fails open to the original when an active processing job rejects', async () => {
    const dependencies = createDependencies();
    const processImage = vi.fn(async () => {
      throw new Error('decode failed at a sensitive local path');
    });
    const { result } = renderHook(() => useComposerAttachments({
      selectedSessionId: 'ses_1',
      dependencies,
      processImage,
    }));

    act(() => result.current.addFiles([
      new File(['original'], 'large.png', { type: 'image/png' }),
    ]));

    await waitFor(() => {
      expect(result.current.attachments[0]).toMatchObject({
        selectedVariant: 'original',
        status: 'failed',
        warning: '图片压缩失败，发送时将使用原图。',
      });
    });
    expect(result.current.attachments[0].compressed).toBeUndefined();
    expect(dependencies.objectUrls.createObjectURL).toHaveBeenCalledTimes(1);
  });

  it('handles a late rejected job after removal without restoring state', async () => {
    const dependencies = createDependencies();
    const deferred = createDeferred<ProcessedImageAttachment>();
    const processImage = vi.fn(() => deferred.promise);
    const { result } = renderHook(() => useComposerAttachments({
      selectedSessionId: 'ses_1',
      dependencies,
      processImage,
    }));
    act(() => result.current.addFiles([
      new File(['original'], 'large.png', { type: 'image/png' }),
    ]));
    const id = result.current.attachments[0].id;
    act(() => result.current.removeAttachment(id));

    await act(async () => {
      deferred.reject(new Error('late decode failure'));
      await deferred.promise.catch(() => undefined);
    });

    expect(result.current.attachments).toEqual([]);
    expect(dependencies.objectUrls.createObjectURL).toHaveBeenCalledTimes(1);
    expect(dependencies.objectUrls.revokeObjectURL)
      .toHaveBeenCalledWith('blob:hook-1');
  });

  it('clears and revokes on a normal session switch', () => {
    const dependencies = createDependencies();
    const { result, rerender } = renderHook(
      ({ sessionId }) => useComposerAttachments({
        selectedSessionId: sessionId,
        dependencies,
      }),
      { initialProps: { sessionId: 'ses_1' } },
    );
    act(() => result.current.addFiles([
      new File(['gif'], 'animation.gif', { type: 'image/gif' }),
    ]));

    rerender({ sessionId: 'ses_2' });

    expect(result.current.attachments).toEqual([]);
    expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledWith('blob:hook-1');
  });

  it('clears before paint and stages transfers from the current session', () => {
    const dependencies = createDependencies();
    const layoutTransferSources: string[] = [];
    const { result, rerender } = renderHook(
      ({ sessionId }) => {
        const composer = useComposerAttachments({
          selectedSessionId: sessionId,
          dependencies,
        });
        useLayoutEffect(() => {
          if (sessionId !== 'ses_2') return;
          const transfer = composer.stageSessionTransfer('ses_probe');
          layoutTransferSources.push(transfer.fromSessionId);
          composer.cancelSessionTransfer(transfer);
        }, [composer, sessionId]);
        return composer;
      },
      { initialProps: { sessionId: 'ses_1' } },
    );
    act(() => result.current.addFiles([
      new File(['gif'], 'animation.gif', { type: 'image/gif' }),
    ]));

    rerender({ sessionId: 'ses_2' });

    expect(result.current.attachments).toEqual([]);
    expect(layoutTransferSources[0]).toBe('ses_2');
    expect(dependencies.objectUrls.revokeObjectURL)
      .toHaveBeenCalledWith('blob:hook-1');
  });

  it('retains URLs for exactly one staged fork transfer then clears next time', () => {
    const dependencies = createDependencies();
    const { result, rerender } = renderHook(
      ({ sessionId }) => useComposerAttachments({
        selectedSessionId: sessionId,
        dependencies,
      }),
      { initialProps: { sessionId: 'ses_old' } },
    );
    act(() => result.current.addFiles([
      new File(['gif'], 'animation.gif', { type: 'image/gif' }),
    ]));

    act(() => {
      result.current.stageSessionTransfer('ses_fork');
    });
    rerender({ sessionId: 'ses_fork' });

    expect(result.current.attachments).toHaveLength(1);
    expect(result.current.attachments[0].original.previewUrl).toBe('blob:hook-1');
    expect(dependencies.objectUrls.revokeObjectURL).not.toHaveBeenCalled();

    rerender({ sessionId: 'ses_other' });

    expect(result.current.attachments).toEqual([]);
    expect(dependencies.objectUrls.revokeObjectURL).toHaveBeenCalledWith('blob:hook-1');
  });

  it('waits for processing and serializes the latest selected variant by captured ID', async () => {
    const dependencies = createDependencies();
    const deferred = createDeferred<ProcessedImageAttachment>();
    const processImage = vi.fn(() => deferred.promise);
    const { result } = renderHook(() => useComposerAttachments({
      selectedSessionId: 'ses_1',
      dependencies,
      processImage,
    }));
    const file = new File(['original'], 'large.png', { type: 'image/png' });
    act(() => result.current.addFiles([file]));
    const ids = result.current.captureAttachmentIds();
    let serialized: Awaited<ReturnType<typeof result.current.serializeByIds>> | undefined;

    const serializePromise = act(async () => {
      serialized = await result.current.serializeByIds(ids);
    });
    expect(serialized).toBeUndefined();

    deferred.resolve(unchanged(file));
    await serializePromise;

    expect(serialized).toEqual([
      expect.objectContaining({
        fileName: 'large.png',
        mimeType: 'image/png',
        preview: 'data:image/png;base64,b3JpZ2luYWw=',
      }),
    ]);
  });

  it('serializes selected image attachments sequentially', async () => {
    const dependencies = createDependencies();
    const firstGate = createDeferred<ArrayBuffer>();
    const secondGate = createDeferred<ArrayBuffer>();
    const started: string[] = [];
    let active = 0;
    let maximumActive = 0;
    const installReadGate = (
      file: File,
      label: string,
      gate: ReturnType<typeof createDeferred<ArrayBuffer>>,
    ) => {
      const read = vi.fn(async () => {
        started.push(label);
        active += 1;
        maximumActive = Math.max(maximumActive, active);
        try {
          return await gate.promise;
        } finally {
          active -= 1;
        }
      });
      vi.spyOn(file, 'arrayBuffer').mockImplementation(read);
      const chunk = new Blob([label], { type: file.type });
      vi.spyOn(chunk, 'arrayBuffer').mockImplementation(read);
      vi.spyOn(file, 'slice').mockReturnValue(chunk);
    };
    const firstFile = new File(['first'], 'first.gif', { type: 'image/gif' });
    const secondFile = new File(['second'], 'second.gif', { type: 'image/gif' });
    installReadGate(
      firstFile,
      'first',
      firstGate,
    );
    installReadGate(
      secondFile,
      'second',
      secondGate,
    );
    const { result } = renderHook(() => useComposerAttachments({
      selectedSessionId: 'ses_1',
      dependencies,
    }));
    act(() => result.current.addFiles([firstFile, secondFile]));
    const ids = result.current.captureAttachmentIds();

    const serialization = result.current.serializeByIds(ids);
    await waitFor(() => expect(started).toEqual(['first']));
    expect(maximumActive).toBe(1);

    firstGate.resolve(new TextEncoder().encode('first').buffer);
    await waitFor(() => expect(started).toEqual(['first', 'second']));
    expect(maximumActive).toBe(1);
    secondGate.resolve(new TextEncoder().encode('second').buffer);

    await expect(serialization).resolves.toHaveLength(2);
    expect(maximumActive).toBe(1);
  });

  it('merges undo records through the restoration entry and reports unsafe records', () => {
    const dependencies = createDependencies();
    const processImage = vi.fn(async (file: File) => unchanged(file));
    const { result } = renderHook(() => useComposerAttachments({
      selectedSessionId: 'ses_1',
      dependencies,
      processImage,
    }));

    let outcome!: ReturnType<typeof result.current.addRestoredAttachments>;
    act(() => {
      outcome = result.current.addRestoredAttachments([
        {
          meta: {
            fileName: 'restored.png',
            mimeType: 'image/png',
            fileSize: 8,
            preview: null,
          },
          imageDataUrl: 'data:image/png;base64,cmVzdG9yZWQ=',
        },
        {
          meta: {
            fileName: 'remote.png',
            mimeType: 'image/png',
            fileSize: 8,
            preview: 'https://example.com/remote.png',
          },
        },
      ]);
    });

    expect(outcome.addedIds).toHaveLength(1);
    expect(outcome.warnings).toEqual([
      '无法恢复远程图片附件；未发起网络请求。',
    ]);
    expect(result.current.attachments).toHaveLength(1);
  });

  it('revokes current URLs on unmount', async () => {
    const dependencies = createDependencies();
    const { result, unmount } = renderHook(() => useComposerAttachments({
      selectedSessionId: 'ses_1',
      dependencies,
    }));
    act(() => result.current.addFiles([
      new File(['gif'], 'animation.gif', { type: 'image/gif' }),
    ]));

    unmount();

    await waitFor(() => {
      expect(dependencies.objectUrls.revokeObjectURL)
        .toHaveBeenCalledWith('blob:hook-1');
    });
  });
});
