import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { FilesService } from './files.service';

describe('FilesService', () => {
  type FilesServiceDeps = ConstructorParameters<typeof FilesService>;

  const repository = {
    expireStaleUploadSessions: jest.fn(),
    findExpiredUploadSessions: jest.fn(),
    sumCommittedStorageBytes: jest.fn(),
    sumReservedStorageBytes: jest.fn(),
    markUploadSessionFailed: jest.fn(),
    findUploadSessionById: jest.fn(),
    findById: jest.fn(),
    delete: jest.fn(),
    findByUserId: jest.fn(),
  };

  const storage = {
    generateObjectKey: jest.fn(),
    createUploadUrl: jest.fn(),
    verifyUpload: jest.fn(),
    deleteObject: jest.fn(),
    getDownloadUrl: jest.fn(),
  };

  const cos = {
    getBucket: jest.fn().mockReturnValue('bucket-1'),
  };

  const safety = {
    check: jest.fn(),
  };

  const quotaService = {
    computeEffectiveQuota: jest.fn(),
  };

  const membershipService = {
    getEntitlements: jest.fn(),
  };

  const tx = {
    uploadSession: {
      create: jest.fn(),
      findUnique: jest.fn(),
      update: jest.fn(),
    },
    uploadedFile: {
      findUnique: jest.fn(),
      create: jest.fn(),
    },
  };

  const prisma = {
    $transaction: jest.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
  };

  let service: FilesService;

  beforeEach(() => {
    jest.clearAllMocks();

    quotaService.computeEffectiveQuota.mockResolvedValue({
      limits: [
        { quotaType: 'file_size_bytes', limit: 100, isUnlimited: false },
        { quotaType: 'storage_bytes', limit: 300, isUnlimited: false },
      ],
    });
    membershipService.getEntitlements.mockResolvedValue({ entitlements: null });
    safety.check.mockResolvedValue({ safe: true });
    storage.generateObjectKey.mockImplementation((userId: string, filename: string, uploadId: string) => (
      `${userId}/202606/${uploadId}-${filename}`
    ));
    storage.createUploadUrl.mockResolvedValue({
      uploadUrl: 'https://example.com/upload',
      objectKey: 'object-key',
      bucket: 'bucket-1',
      region: 'ap-shanghai',
      expiresIn: 3600,
    });
    storage.deleteObject.mockResolvedValue(undefined);
    repository.findExpiredUploadSessions.mockResolvedValue([]);
    repository.sumCommittedStorageBytes.mockResolvedValue(50);
    repository.sumReservedStorageBytes.mockResolvedValue(25);
    tx.uploadSession.create.mockResolvedValue({});
    tx.uploadSession.findUnique.mockResolvedValue(null);
    tx.uploadSession.update.mockResolvedValue({});
    tx.uploadedFile.findUnique.mockResolvedValue(null);
    tx.uploadedFile.create.mockResolvedValue({
      id: 'file-1',
      userId: 'u1',
      objectKey: 'object-key',
      sizeBytes: 120,
    });

    service = new FilesService(
      repository as unknown as FilesServiceDeps[0],
      storage as unknown as FilesServiceDeps[1],
      cos as unknown as FilesServiceDeps[2],
      safety as unknown as FilesServiceDeps[3],
      quotaService as unknown as FilesServiceDeps[4],
      membershipService as unknown as FilesServiceDeps[5],
      prisma as unknown as FilesServiceDeps[6],
    );
  });

  it('rejects declared size over file limit before reserving storage', async () => {
    await expect(service.requestUploadUrl('u1', {
      filename: 'doc.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 101,
    })).rejects.toMatchObject({
      response: expect.objectContaining({
        errorCode: 'FILE_SIZE_LIMIT_EXCEEDED',
        quotaType: 'file_size_bytes',
        limit: 100,
      }),
    });

    expect(prisma.$transaction).not.toHaveBeenCalled();
    expect(storage.createUploadUrl).not.toHaveBeenCalled();
  });

  it('creates upload session with stable storage operation id', async () => {
    const result = await service.requestUploadUrl('u1', {
      filename: 'doc.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 80,
    });

    expect(result.uploadId).toEqual(expect.any(String));
    expect(result.quotaOperationId).toBe(`storage-upload:${result.uploadId}`);
    expect(tx.uploadSession.create).toHaveBeenCalledWith(expect.objectContaining({
      data: expect.objectContaining({
        id: result.uploadId,
        quotaOperationId: `storage-upload:${result.uploadId}`,
        reservedBytes: BigInt(80),
        declaredSize: BigInt(80),
        status: 'created',
      }),
    }));
    expect(storage.createUploadUrl).toHaveBeenCalledWith('u1', expect.objectContaining({
      filename: 'doc.pdf',
      objectKey: expect.stringContaining(`${result.uploadId}-doc.pdf`),
    }), 3600);
  });

  it('completes upload with actual size lower than declared size', async () => {
    repository.findUploadSessionById.mockResolvedValue({
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'created',
      expiresAt: new Date(Date.now() + 60_000),
      uploadedFile: null,
    });
    storage.verifyUpload.mockResolvedValue({
      size: 60,
      etag: 'etag',
      contentType: 'application/pdf',
    });
    tx.uploadSession.findUnique.mockResolvedValue({
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'created',
      uploadedAt: null,
      completedAt: null,
      uploadedFile: null,
    });

    const result = await service.confirmUpload('u1', { uploadId: 'upload-1' });

    expect(result).toMatchObject({ id: 'file-1' });
    expect(tx.uploadedFile.create).toHaveBeenCalledWith(expect.objectContaining({
      data: expect.objectContaining({
        objectKey: 'obj-1',
        sizeBytes: 60,
      }),
    }));
    expect(tx.uploadSession.update).toHaveBeenLastCalledWith(expect.objectContaining({
      where: { id: 'upload-1' },
      data: expect.objectContaining({
        status: 'completed',
        reservedBytes: BigInt(60),
      }),
    }));
  });

  it('completes upload with actual size higher than declared size when quota still allows it', async () => {
    repository.findUploadSessionById.mockResolvedValue({
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'created',
      expiresAt: new Date(Date.now() + 60_000),
      uploadedFile: null,
    });
    storage.verifyUpload.mockResolvedValue({
      size: 90,
      etag: 'etag',
      contentType: 'application/pdf',
    });
    repository.sumCommittedStorageBytes.mockResolvedValue(50);
    repository.sumReservedStorageBytes.mockResolvedValue(20);
    tx.uploadSession.findUnique.mockResolvedValue({
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'created',
      uploadedAt: null,
      completedAt: null,
      uploadedFile: null,
    });

    await service.confirmUpload('u1', { uploadId: 'upload-1' });

    expect(tx.uploadedFile.create).toHaveBeenCalledWith(expect.objectContaining({
      data: expect.objectContaining({
        sizeBytes: 90,
      }),
    }));
    expect(tx.uploadSession.update).toHaveBeenLastCalledWith(expect.objectContaining({
      data: expect.objectContaining({
        reservedBytes: BigInt(90),
      }),
    }));
  });

  it('returns existing file on repeated complete without re-verifying provider state', async () => {
    const uploadedFile = { id: 'file-1', userId: 'u1', objectKey: 'obj-1' };
    repository.findUploadSessionById.mockResolvedValue({
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'completed',
      expiresAt: new Date(Date.now() + 60_000),
      uploadedFile,
    });

    await expect(service.confirmUpload('u1', { uploadId: 'upload-1' })).resolves.toBe(uploadedFile);
    expect(storage.verifyUpload).not.toHaveBeenCalled();
    expect(prisma.$transaction).not.toHaveBeenCalled();
  });

  it('fails complete when actual file size exceeds limit and cleans up object', async () => {
    repository.findUploadSessionById.mockResolvedValue({
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'created',
      expiresAt: new Date(Date.now() + 60_000),
      uploadedFile: null,
    });
    storage.verifyUpload.mockResolvedValue({
      size: 150,
      etag: 'etag',
      contentType: 'application/pdf',
    });

    await expect(service.confirmUpload('u1', { uploadId: 'upload-1' })).rejects.toMatchObject({
      response: expect.objectContaining({
        errorCode: 'FILE_SIZE_LIMIT_EXCEEDED',
      }),
    });

    expect(repository.markUploadSessionFailed).toHaveBeenCalledWith('upload-1');
    expect(storage.deleteObject).toHaveBeenCalledWith('obj-1');
    expect(prisma.$transaction).not.toHaveBeenCalled();
  });

  it('blocks completion when actual size would push storage over limit', async () => {
    repository.findUploadSessionById.mockResolvedValue({
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'created',
      expiresAt: new Date(Date.now() + 60_000),
      uploadedFile: null,
    });
    storage.verifyUpload.mockResolvedValue({
      size: 90,
      etag: 'etag',
      contentType: 'application/pdf',
    });
    repository.sumCommittedStorageBytes.mockResolvedValue(120);
    repository.sumReservedStorageBytes.mockResolvedValue(110);
    tx.uploadSession.findUnique.mockResolvedValue({
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'created',
      uploadedAt: null,
      completedAt: null,
      uploadedFile: null,
    });

    await expect(service.confirmUpload('u1', { uploadId: 'upload-1' })).rejects.toMatchObject({
      response: expect.objectContaining({
        errorCode: 'STORAGE_LIMIT_EXCEEDED',
        quotaType: 'storage_bytes',
      }),
    });

    expect(tx.uploadSession.update).toHaveBeenCalledWith(expect.objectContaining({
      where: { id: 'upload-1' },
      data: expect.objectContaining({ status: 'failed' }),
    }));
    expect(tx.uploadedFile.create).not.toHaveBeenCalled();
    expect(storage.deleteObject).toHaveBeenCalledWith('obj-1');
  });

  it('marks session failed and cleans object when database write fails after provider verification', async () => {
    repository.findUploadSessionById.mockResolvedValue({
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'created',
      expiresAt: new Date(Date.now() + 60_000),
      uploadedFile: null,
    });
    storage.verifyUpload.mockResolvedValue({
      size: 80,
      etag: 'etag',
      contentType: 'application/pdf',
    });
    tx.uploadSession.findUnique.mockResolvedValue({
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'created',
      uploadedAt: null,
      completedAt: null,
      uploadedFile: null,
    });
    tx.uploadedFile.create.mockRejectedValueOnce(new Error('db failed'));

    await expect(service.confirmUpload('u1', { uploadId: 'upload-1' })).rejects.toThrow('db failed');

    expect(repository.markUploadSessionFailed).toHaveBeenCalledWith('upload-1');
    expect(storage.deleteObject).toHaveBeenCalledWith('obj-1');
  });

  it('rejects expired upload session before provider verification', async () => {
    repository.findExpiredUploadSessions
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([{ id: 'upload-1', objectKey: 'obj-1' }]);
    repository.findUploadSessionById.mockResolvedValue({
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'created',
      expiresAt: new Date(Date.now() - 1_000),
      uploadedFile: null,
    });

    await expect(service.confirmUpload('u1', { uploadId: 'upload-1' })).rejects.toBeInstanceOf(BadRequestException);

    expect(storage.verifyUpload).not.toHaveBeenCalled();
    expect(repository.findExpiredUploadSessions).toHaveBeenCalledTimes(2);
    expect(storage.deleteObject).toHaveBeenCalledWith('obj-1');
  });

  it('returns same file for five concurrent complete requests', async () => {
    const session = {
      id: 'upload-1',
      userId: 'u1',
      objectKey: 'obj-1',
      status: 'created',
      uploadedAt: null as Date | null,
      completedAt: null as Date | null,
      uploadedFile: null as { id: string; userId: string; objectKey: string; sizeBytes: number } | null,
    };
    const persistedFile = {
      id: 'file-1',
      userId: 'u1',
      objectKey: 'obj-1',
      sizeBytes: 80,
    };
    let createdFileCount = 0;

    repository.findUploadSessionById.mockImplementation(async () => ({
      ...session,
      expiresAt: new Date(Date.now() + 60_000),
      uploadedFile: session.uploadedFile,
    }));
    storage.verifyUpload.mockResolvedValue({
      size: 80,
      etag: 'etag',
      contentType: 'application/pdf',
    });
    tx.uploadSession.findUnique.mockImplementation(async () => ({
      ...session,
      uploadedFile: session.uploadedFile,
    }));
    tx.uploadedFile.findUnique.mockImplementation(async () => session.uploadedFile);
    tx.uploadedFile.create.mockImplementation(async () => {
      if (session.uploadedFile) {
        const duplicateError: Error & { code?: string } = new Error('duplicate objectKey');
        duplicateError.code = 'P2002';
        throw duplicateError;
      }
      createdFileCount += 1;
      session.uploadedFile = persistedFile;
      return persistedFile;
    });
    tx.uploadSession.update.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => {
      session.status = String(data.status ?? session.status);
      session.uploadedAt = (data.uploadedAt as Date | undefined) ?? session.uploadedAt;
      session.completedAt = (data.completedAt as Date | undefined) ?? session.completedAt;
      return { ...session };
    });

    const results = await Promise.all(
      Array.from({ length: 5 }, () => service.confirmUpload('u1', { uploadId: 'upload-1' })),
    );

    expect(createdFileCount).toBe(1);
    expect(results).toHaveLength(5);
    expect(results.every((item) => item.id === 'file-1')).toBe(true);
  });

  it('deletes object before removing file record', async () => {
    repository.findById.mockResolvedValue({
      id: 'file-1',
      userId: 'u1',
      objectKey: 'obj-1',
    });

    await expect(service.deleteFile('u1', 'file-1')).resolves.toEqual({ success: true });

    expect(storage.deleteObject).toHaveBeenCalledWith('obj-1');
    expect(repository.delete).toHaveBeenCalledWith('file-1');
  });

  it('allows repeated delete attempts to surface not found without double deleting storage', async () => {
    repository.findById
      .mockResolvedValueOnce({
        id: 'file-1',
        userId: 'u1',
        objectKey: 'obj-1',
      })
      .mockResolvedValueOnce(null);

    await expect(service.deleteFile('u1', 'file-1')).resolves.toEqual({ success: true });
    await expect(service.deleteFile('u1', 'file-1')).rejects.toMatchObject({ message: '文件不存在' });

    expect(storage.deleteObject).toHaveBeenCalledTimes(1);
    expect(repository.delete).toHaveBeenCalledTimes(1);
  });

  it('admin delete removes object and file record', async () => {
    repository.findById.mockResolvedValue({
      id: 'file-1',
      userId: 'u9',
      objectKey: 'obj-admin-1',
    });

    await expect(service.deleteFileAsAdmin('file-1')).resolves.toEqual({ success: true });

    expect(storage.deleteObject).toHaveBeenCalledWith('obj-admin-1');
    expect(repository.delete).toHaveBeenCalledWith('file-1');
  });

  it('cleans expired upload sessions and orphaned objects before new upload url creation', async () => {
    repository.findExpiredUploadSessions.mockResolvedValue([
      { id: 'expired-1', objectKey: 'orphan-1' },
      { id: 'expired-2', objectKey: 'orphan-2' },
    ]);

    await service.requestUploadUrl('u1', {
      filename: 'doc.pdf',
      mimeType: 'application/pdf',
      sizeBytes: 80,
    });

    expect(repository.expireStaleUploadSessions).toHaveBeenCalled();
    expect(storage.deleteObject).toHaveBeenCalledWith('orphan-1');
    expect(storage.deleteObject).toHaveBeenCalledWith('orphan-2');
  });

  it('forbids deleting another user file', async () => {
    repository.findById.mockResolvedValue({
      id: 'file-1',
      userId: 'u2',
      objectKey: 'obj-1',
    });

    await expect(service.deleteFile('u1', 'file-1')).rejects.toBeInstanceOf(ForbiddenException);
    expect(storage.deleteObject).not.toHaveBeenCalled();
    expect(repository.delete).not.toHaveBeenCalled();
  });
});
