import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeleteObjectsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { access, mkdir, rm, stat, unlink, writeFile } from 'fs/promises';
import { MediaStorageService } from '../../common/media/media-storage.service';
import { ManagedStorageService } from './managed-storage.service';

jest.mock('fs/promises', () => ({
  access: jest.fn(), mkdir: jest.fn(), rm: jest.fn(), stat: jest.fn(), unlink: jest.fn(), writeFile: jest.fn(),
}));

type ManagedInternals = {
  deleteS3Prefix(prefix: string): Promise<void>;
  getS3Bucket(): string;
  getS3Client(): { send(command: unknown): Promise<unknown> };
  resolveCacheControl(key: string): string;
};

const internals = (service: ManagedStorageService): ManagedInternals =>
  service as unknown as ManagedInternals;

describe('ManagedStorageService', () => {
  const values: Record<string, unknown> = {
    'storage.provider': 'local', 'storage.basePath': 'uploads', publicBaseUrl: 'https://api.test',
  };
  const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
  const media = {
    uploadFile: jest.fn(), uploadFileToKey: jest.fn(), deleteFile: jest.fn(),
  } as unknown as MediaStorageService;

  beforeEach(() => {
    jest.clearAllMocks();
    Object.assign(values, {
      'storage.provider': 'local', 'storage.basePath': 'uploads', publicBaseUrl: 'https://api.test',
      'storage.publicBaseUrl': '', 'storage.s3.bucket': '', 'storage.s3.endpoint': '',
      'storage.s3.accessKeyId': '', 'storage.s3.secretAccessKey': '',
      'storage.s3.forcePathStyle': false, 'storage.s3.healthWriteTestEnabled': false,
    });
    (mkdir as jest.Mock).mockResolvedValue(undefined);
    (writeFile as jest.Mock).mockResolvedValue(undefined);
    (unlink as jest.Mock).mockResolvedValue(undefined);
    (rm as jest.Mock).mockResolvedValue(undefined);
    (stat as jest.Mock).mockResolvedValue({});
    (access as jest.Mock).mockResolvedValue(undefined);
  });

  it('saves one or many files locally with normalized managed paths', async () => {
    const service = new ManagedStorageService(config, media);
    const one = await service.saveFile({
      folderSegments: ['/posts\\images/', 'user-1'], extension: '.jpg', buffer: Buffer.from('jpg'),
      contentType: 'image/jpeg', fileNamePrefix: 'photo',
    });
    expect(one).toMatch(/^\/uploads\/posts\/images\/user-1\/photo-[0-9a-f-]+\.jpg$/);
    expect(writeFile).toHaveBeenCalledWith(expect.stringContaining('photo-'), Buffer.from('jpg'));

    await expect(service.saveFiles({ folderSegments: [], files: [] })).resolves.toEqual({});
    await expect(service.saveFiles({
      folderSegments: ['hls'],
      files: [
        { relativePath: 'master.m3u8', buffer: Buffer.from('playlist') },
        { relativePath: 'stream_0\\segment.m4s', buffer: Buffer.from('segment') },
      ],
    })).resolves.toEqual({
      'master.m3u8': '/uploads/hls/master.m3u8',
      'stream_0/segment.m4s': '/uploads/hls/stream_0/segment.m4s',
    });
    expect(mkdir).toHaveBeenCalled();
  });

  it.each(['', '.', '../secret', 'a//b'])('rejects unsafe relative file path %p', async (relativePath) => {
    const service = new ManagedStorageService(config, media);
    await expect(service.saveFiles({ folderSegments: [], files: [{ relativePath, buffer: Buffer.alloc(1) }] }))
      .rejects.toBeInstanceOf(BadRequestException);
  });

  it('delegates S3 writes and returns uploaded URLs', async () => {
    values['storage.provider'] = 's3';
    (media.uploadFile as jest.Mock).mockResolvedValue({ url: 'https://cdn/one.jpg' });
    (media.uploadFileToKey as jest.Mock).mockResolvedValue({ url: 'https://cdn/master.m3u8' });
    const service = new ManagedStorageService(config, media);
    await expect(service.saveFile({ folderSegments: ['posts'], extension: '.jpg', buffer: Buffer.from('x') }))
      .resolves.toBe('https://cdn/one.jpg');
    expect(media.uploadFile).toHaveBeenCalledWith(expect.objectContaining({ size: 1 }), 'posts');
    await expect(service.saveFiles({ folderSegments: ['hls'], files: [{ relativePath: 'master.m3u8', buffer: Buffer.from('x') }] }))
      .resolves.toEqual({ 'master.m3u8': 'https://cdn/master.m3u8' });
    expect(media.uploadFileToKey).toHaveBeenCalledWith(expect.anything(), 'uploads/hls/master.m3u8');
  });

  it('deletes and resolves only safe local managed paths', async () => {
    const service = new ManagedStorageService(config, media);
    await service.deleteFile();
    await service.deleteFile('https://external/file.jpg');
    await service.deleteFile('/uploads/../secret');
    expect(unlink).not.toHaveBeenCalled();
    await service.deleteFile('/uploads/posts/file.jpg?token=one');
    expect(unlink).toHaveBeenCalledWith(expect.stringMatching(/uploads[\\/]posts[\\/]file\.jpg$/));
    (unlink as jest.Mock).mockRejectedValueOnce(new Error('missing'));
    await expect(service.deleteFile('/uploads/posts/missing.jpg')).resolves.toBeUndefined();
    expect(service.resolveLocalFilePath('/uploads/posts/file.jpg')).toMatch(/uploads[\\/]posts[\\/]file\.jpg$/);
    expect(service.resolveLocalFilePath('/other/file.jpg')).toBeNull();
    values['storage.provider'] = 's3';
    expect(service.resolveLocalFilePath('/uploads/posts/file.jpg')).toBeNull();
  });

  it('deletes safe containing directories locally without deleting the root', async () => {
    const service = new ManagedStorageService(config, media);
    await service.deleteContainingDirectory();
    await service.deleteContainingDirectory('/uploads/file.jpg');
    await service.deleteContainingDirectory('/uploads/../secret/file.jpg');
    expect(rm).not.toHaveBeenCalled();
    await service.deleteContainingDirectory('/uploads/posts/hls/master.m3u8');
    expect(rm).toHaveBeenCalledWith(expect.stringMatching(/uploads[\\/]posts[\\/]hls$/), { recursive: true, force: true });
  });

  it('deletes S3 objects and containing prefixes resolved from public and endpoint URLs', async () => {
    Object.assign(values, {
      'storage.provider': 's3', 'storage.publicBaseUrl': 'https://cdn.test/',
      'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test',
      'storage.s3.forcePathStyle': true,
    });
    const service = new ManagedStorageService(config, media);
    await service.deleteFile('https://cdn.test/uploads/posts/file.jpg?x=1');
    expect(media.deleteFile).toHaveBeenCalledWith('uploads/posts/file.jpg');
    const deletePrefix = jest.spyOn(internals(service), 'deleteS3Prefix').mockResolvedValue(undefined);
    await service.deleteContainingDirectory('https://cdn.test/uploads/posts/hls/master.m3u8');
    expect(deletePrefix).toHaveBeenCalledWith('uploads/posts/hls/');
    await service.deleteContainingDirectory('https://cdn.test/uploads/file.jpg');
    expect(deletePrefix).toHaveBeenCalledTimes(1);
    values['storage.publicBaseUrl'] = '';
    await service.deleteFile('https://s3.test/bucket/uploads/posts/two.jpg');
    expect(media.deleteFile).toHaveBeenCalledWith('uploads/posts/two.jpg');
  });

  it('reports healthy and failed local probes', async () => {
    const service = new ManagedStorageService(config, media);
    await expect(service.getHealth()).resolves.toEqual(expect.objectContaining({
      provider: 'local', isLocalStorage: true, uploadPathExists: true,
      uploadPathReadable: true, uploadPathWritable: true,
    }));
    (writeFile as jest.Mock).mockRejectedValueOnce(new Error('read only'));
    (unlink as jest.Mock).mockRejectedValueOnce(new Error('not created'));
    (stat as jest.Mock).mockRejectedValueOnce(new Error('missing'));
    (access as jest.Mock).mockRejectedValueOnce(new Error('denied'));
    const failed = await service.getHealth();
    expect(failed).toEqual(expect.objectContaining({ uploadPathExists: false, uploadPathReadable: false, uploadPathWritable: false }));
    expect(failed.local).toEqual(expect.objectContaining({ error: 'read only' }));
  });

  it('reports incomplete and reachable S3 health including write probes', async () => {
    values['storage.provider'] = 's3';
    const service = new ManagedStorageService(config, media);
    expect(await service.getHealth()).toEqual(expect.objectContaining({ s3: expect.objectContaining({ configured: false, reachable: false }) }));

    Object.assign(values, {
      'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test',
      'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret',
      'storage.s3.healthWriteTestEnabled': true,
    });
    const client = { send: jest.fn().mockResolvedValue({}) };
    jest.spyOn(internals(service), 'getS3Client').mockReturnValue(client);
    const healthy = await service.getHealth();
    expect(healthy.s3).toEqual(expect.objectContaining({ configured: true, reachable: true, writable: true }));
    expect(client.send).toHaveBeenCalledTimes(3);

    client.send.mockRejectedValueOnce(new Error('unreachable'));
    const unhealthy = await service.getHealth();
    expect(unhealthy.s3).toEqual(expect.objectContaining({ reachable: false, writable: false, error: 'unreachable' }));
    service.onModuleDestroy();
  });

  it('validates S3 setup and paginates prefix deletion', async () => {
    values['storage.provider'] = 's3';
    const service = new ManagedStorageService(config, media);
    expect(() => internals(service).getS3Bucket()).toThrow(BadRequestException);
    expect(() => internals(service).getS3Client()).toThrow(BadRequestException);

    Object.assign(values, { 'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test', 'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret' });
    const client = { send: jest.fn()
      .mockResolvedValueOnce({ Contents: [{ Key: 'uploads/a' }, {}], IsTruncated: true, NextContinuationToken: 'next' })
      .mockResolvedValueOnce({})
      .mockResolvedValueOnce({ Contents: [{ Key: 'uploads/b' }], IsTruncated: false })
      .mockResolvedValueOnce({}) };
    jest.spyOn(internals(service), 'getS3Client').mockReturnValue(client);
    await internals(service).deleteS3Prefix('uploads/posts/');
    expect(client.send.mock.calls.filter(([command]) => command instanceof ListObjectsV2Command)).toHaveLength(2);
    expect(client.send.mock.calls.filter(([command]) => command instanceof DeleteObjectsCommand)).toHaveLength(2);
    expect(internals(service).resolveCacheControl('master.m3u8')).toBe('public, max-age=300');
    expect(internals(service).resolveCacheControl('file.jpg')).toContain('immutable');
  });
});
