import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import { MediaStorageService } from './media-storage.service';

jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: jest.fn() }));

type StorageInternals = {
  getS3Client(): { send(command: unknown): Promise<any> };
  getSignedUrlExpiresSeconds(): number;
  cacheSignedUrl(key: string, url: string, seconds: number): void;
  normalizeFolder(folder: string): string;
  normalizeUserSegment(user: string): string;
  normalizeKey(key: string): string;
};

const internals = (service: MediaStorageService): StorageInternals =>
  service as unknown as StorageInternals;

describe('MediaStorageService behavior', () => {
  const values: Record<string, unknown> = {
    'storage.provider': 's3', 'storage.basePath': '/uploads/',
    'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test/',
    'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret',
    'storage.s3.forcePathStyle': true, 'storage.publicBaseUrl': 'https://cdn.test/',
    'storage.mediaAccessMode': 'public', publicBaseUrl: 'https://api.test/', globalPrefix: '/api/v2/',
    'security.refreshTokenHashSecret': 'signing-secret',
  };
  const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
  const logger = { error: jest.fn() } as unknown as AppLoggerService;
  let send: jest.Mock;
  let service: MediaStorageService;

  beforeEach(() => {
    jest.clearAllMocks();
    Object.assign(values, {
      'storage.provider': 's3', 'storage.basePath': '/uploads/',
      'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test/',
      'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret',
      'storage.s3.forcePathStyle': true, 'storage.publicBaseUrl': 'https://cdn.test/',
      'storage.mediaAccessMode': 'public', publicBaseUrl: 'https://api.test/', globalPrefix: '/api/v2/',
      'storage.signedUrlExpiresSeconds': 60, 'security.refreshTokenHashSecret': 'signing-secret',
    });
    send = jest.fn().mockResolvedValue({});
    service = new MediaStorageService(config, logger);
    jest.spyOn(internals(service), 'getS3Client').mockReturnValue({ send });
  });

  it('uploads generated and exact keys with content metadata', async () => {
    const result = await service.uploadFile({ buffer: Buffer.from('abc'), originalname: 'photo.JPG', mimetype: 'image/jpeg' }, '/posts\\images/');
    expect(result).toEqual(expect.objectContaining({ key: expect.stringMatching(/^uploads\/posts\/images\/media-[0-9a-f-]+\.jpg$/i), url: expect.stringContaining('https://cdn.test/uploads/'), size: 3, originalName: 'photo.JPG' }));
    expect(send.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand);

    await expect(service.uploadFileToKey({ buffer: Buffer.from('x') }, '/uploads/hls/master.m3u8')).resolves.toEqual(expect.objectContaining({ key: 'uploads/hls/master.m3u8', mimeType: 'application/octet-stream' }));
    const command = send.mock.calls[1][0] as PutObjectCommand;
    expect(command.input).toEqual(expect.objectContaining({ CacheControl: 'public, max-age=300', Key: 'uploads/hls/master.m3u8' }));
    await expect(service.uploadFileToKey({ buffer: Buffer.alloc(0) }, '../bad')).rejects.toBeInstanceOf(BadRequestException);
  });

  it('deletes valid keys and ignores invalid keys', async () => {
    await service.deleteFile('../bad');
    expect(send).not.toHaveBeenCalled();
    await service.deleteFile('/uploads/file.jpg');
    expect(send.mock.calls[0][0]).toBeInstanceOf(DeleteObjectCommand);
  });

  it('builds public, path-style, and virtual endpoint URLs', () => {
    expect(service.getPublicUrl('/uploads/file.jpg')).toBe('https://cdn.test/uploads/file.jpg');
    values['storage.publicBaseUrl'] = '';
    expect(service.getPublicUrl('uploads/file.jpg')).toBe('https://s3.test/bucket/uploads/file.jpg');
    values['storage.s3.forcePathStyle'] = false;
    expect(service.getPublicUrl('uploads/file.jpg')).toBe('https://s3.test/uploads/file.jpg');
    expect(() => service.getPublicUrl('../bad')).toThrow(BadRequestException);
  });

  it('resolves managed keys from relative, CDN, endpoint, and application URLs', () => {
    expect(service.resolveKeyFromUrl('/uploads/posts/a.jpg?token=1')).toBe('uploads/posts/a.jpg');
    expect(service.resolveKeyFromUrl('https://cdn.test/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
    values['storage.publicBaseUrl'] = '';
    expect(service.resolveKeyFromUrl('https://s3.test/bucket/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
    expect(service.resolveKeyFromUrl('https://api.test/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
    expect(service.resolveKeyFromUrl('https://external.test/a.jpg')).toBeNull();
    expect(service.resolveKeyFromUrl('not a url')).toBeNull();
  });

  it('loads object text and validates missing bodies and unsafe keys', async () => {
    send.mockResolvedValueOnce({ Body: { transformToString: jest.fn().mockResolvedValue('#EXTM3U') } });
    await expect(service.getObjectText('uploads/master.m3u8')).resolves.toBe('#EXTM3U');
    expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand);
    send.mockResolvedValueOnce({});
    await expect(service.getObjectText('uploads/empty.txt')).rejects.toThrow('object body is empty');
    await expect(service.getObjectText('../bad')).rejects.toBeInstanceOf(BadRequestException);
  });

  it('builds unsigned and signed HLS endpoints and validates signatures', () => {
    const unsigned = service.getHlsPlaylistUrl('uploads/folder name/master.m3u8');
    expect(unsigned).toBe('https://api.test/api/v2/media/hls/uploads/folder%20name/master.m3u8');
    values['storage.mediaAccessMode'] = 'signed';
    const signed = service.getHlsPlaylistUrl('uploads/master.m3u8');
    const url = new URL(signed);
    const expires = url.searchParams.get('expires') ?? '';
    const signature = url.searchParams.get('signature') ?? '';
    expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', expires, signature)).toBe(true);
    expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', '1', signature)).toBe(false);
    expect(service.isValidHlsPlaylistAccess('../bad', expires, signature)).toBe(false);
    expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', expires, 'short')).toBe(false);
    expect(() => service.getHlsPlaylistUrl('uploads/video.mp4')).toThrow(BadRequestException);
  });

  it('caches signed object URLs until their refresh boundary', async () => {
    values['storage.mediaAccessMode'] = 'signed';
    jest.mocked(getSignedUrl).mockResolvedValue('https://signed.test/one');
    await expect(service.getSignedObjectUrl('uploads/a.jpg')).resolves.toBe('https://signed.test/one');
    await expect(service.getSignedObjectUrl('uploads/a.jpg')).resolves.toBe('https://signed.test/one');
    expect(getSignedUrl).toHaveBeenCalledTimes(1);
    await expect(service.getSignedObjectUrl('../bad')).resolves.toBe('');
    service.onModuleDestroy();
    await service.getSignedObjectUrl('uploads/a.jpg');
    expect(getSignedUrl).toHaveBeenCalledTimes(2);
  });

  it('checks managed prefixes and clamps signing expiration', () => {
    expect(service.isManagedKey('/uploads/posts/a.jpg')).toBe(true);
    expect(service.isManagedKey('private/a.jpg')).toBe(false);
    values['storage.signedUrlExpiresSeconds'] = 0;
    expect(internals(service).getSignedUrlExpiresSeconds()).toBe(1);
    values['storage.signedUrlExpiresSeconds'] = 9999999;
    expect(internals(service).getSignedUrlExpiresSeconds()).toBe(604800);
  });

  it('validates configuration and normalization helpers', () => {
    expect(internals(service).normalizeFolder('posts\\images')).toBe('posts/images');
    expect(() => internals(service).normalizeFolder('../bad')).toThrow(BadRequestException);
    expect(internals(service).normalizeUserSegment(' user_1-2 ')).toBe('user_1-2');
    expect(() => internals(service).normalizeUserSegment('bad/user')).toThrow(BadRequestException);
    expect(internals(service).normalizeKey('a\\b')).toBe('a/b');
    expect(internals(service).normalizeKey('a//b')).toBe('');
    values['storage.s3.bucket'] = '';
    expect(() => service.getBucketName()).toThrow(BadRequestException);
    values['storage.s3.bucket'] = 'bucket';
    const unconfigured = new MediaStorageService({ get: jest.fn(() => '') } as unknown as ConfigService, logger);
    expect(() => internals(unconfigured).getS3Client()).toThrow(BadRequestException);
  });

  it('evicts the oldest signing cache entry at its safety limit', () => {
    const target = internals(service);
    for (let index = 0; index < 10001; index += 1) {
      target.cacheSignedUrl(`uploads/${index}`, `url-${index}`, 10);
    }
    expect((service as unknown as { signedUrlCache: Map<string, unknown> }).signedUrlCache.size).toBe(10000);
    expect((service as unknown as { signedUrlCache: Map<string, unknown> }).signedUrlCache.has('uploads/0')).toBe(false);
  });
});
