import { BadRequestException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm';
import type { TenantEntity } from '../../entities/tenant.entity';
import { LocalStorageService } from '../storage/local-storage.service';
import { MediaService } from './media.service';
import type { MediaRepository } from './media.repository';
import type { BaseMediaEntity } from './base-media.entity';

type Row = BaseMediaEntity & { hotel_id: string };

const url = (path: string) => `/api/uploads/${path}`;

function makeRow(over: Partial<Row>): Row {
  return {
    id: 'm1',
    tenant_id: 't1',
    hotel_id: 'h1',
    url: url('acme/hotels/1-a.jpg'),
    media_type: 'image',
    filename: '1-a.jpg',
    is_cover: true,
    sort_order: 0,
    ...over,
  } as Row;
}

describe('MediaService', () => {
  let storage: LocalStorageService;
  let tenantRepo: { findOne: jest.Mock };
  let repo: {
    parentColumn: string;
    currentTenantId: jest.Mock;
    findByUrl: jest.Mock;
    findForParent: jest.Mock;
    parentIdOf: (row: Row) => string;
    softDelete: jest.Mock;
    update: jest.Mock;
    saveMany: jest.Mock;
  };
  let service: MediaService;
  const asRepo = () => repo as unknown as MediaRepository<Row>;

  beforeEach(() => {
    const config = { get: () => 'uploads' } as unknown as ConfigService;
    storage = new LocalStorageService(config);
    jest.spyOn(storage, 'exists').mockResolvedValue(true);
    jest.spyOn(storage, 'move').mockResolvedValue(undefined);
    tenantRepo = { findOne: jest.fn().mockResolvedValue({ subdomain: 'acme' }) };
    repo = {
      parentColumn: 'hotel_id',
      currentTenantId: jest.fn().mockReturnValue('t1'),
      findByUrl: jest.fn().mockResolvedValue(null),
      findForParent: jest.fn().mockResolvedValue([]),
      parentIdOf: (row) => row.hotel_id,
      softDelete: jest.fn(),
      update: jest.fn(),
      saveMany: jest.fn(),
    };
    service = new MediaService(storage, tenantRepo as unknown as Repository<TenantEntity>);
  });

  describe('prepare', () => {
    it('returns undefined when the payload omitted media', async () => {
      await expect(service.prepare(asRepo(), 'hotels', undefined)).resolves.toBeUndefined();
    });

    it("rejects another tenant's file (cross-tenant)", async () => {
      await expect(
        service.prepare(asRepo(), 'hotels', [{ url: url('victim/hotels/1-a.jpg') }]),
      ).rejects.toThrow(BadRequestException);
      expect(tenantRepo.findOne).toHaveBeenCalledWith(
        expect.objectContaining({ where: { id: 't1' } }),
      );
    });

    it("rejects another module's file and non-module URLs", async () => {
      for (const bad of [
        url('acme/activities/1-a.jpg'),
        url('acme/1-a.jpg'),
        url('deleted/acme/hotels/1-a.jpg'),
        'https://cdn.example.com/x.jpg',
      ]) {
        await expect(service.prepare(asRepo(), 'hotels', [{ url: bad }])).rejects.toThrow(
          BadRequestException,
        );
      }
    });

    it('rejects videos for photo-only modules', async () => {
      await expect(
        service.prepare(asRepo(), 'vehicles', [{ url: url('acme/vehicles/1-a.mp4') }]),
      ).rejects.toThrow(/Videos are not allowed/);
    });

    it('enforces the photo and video limits', async () => {
      const photos = Array.from({ length: 21 }, (_, i) => ({ url: url(`acme/hotels/${i}-a.jpg`) }));
      await expect(service.prepare(asRepo(), 'hotels', photos)).rejects.toThrow(/20 photos/);

      const videos = Array.from({ length: 6 }, (_, i) => ({ url: url(`acme/hotels/${i}-a.mp4`) }));
      await expect(service.prepare(asRepo(), 'hotels', videos)).rejects.toThrow(/5 videos/);
    });

    it('makes the first photo the cover when none is marked, and derives type from the key', async () => {
      const result = await service.prepare(asRepo(), 'hotels', [
        { url: url('acme/hotels/1-a.mp4') },
        { url: url('acme/hotels/2-b.png') },
        { url: url('acme/hotels/2-b.png') },
      ]);
      expect(result).toEqual([
        {
          url: url('acme/hotels/1-a.mp4'),
          media_type: 'video',
          filename: '1-a.mp4',
          is_cover: false,
          sort_order: 0,
        },
        {
          url: url('acme/hotels/2-b.png'),
          media_type: 'image',
          filename: '2-b.png',
          is_cover: true,
          sort_order: 1,
        },
      ]);
    });

    it('rejects two covers and a video cover', async () => {
      await expect(
        service.prepare(asRepo(), 'hotels', [
          { url: url('acme/hotels/1-a.jpg'), is_cover: true },
          { url: url('acme/hotels/2-b.jpg'), is_cover: true },
        ]),
      ).rejects.toThrow(/Only one photo/);
      await expect(
        service.prepare(asRepo(), 'hotels', [{ url: url('acme/hotels/1-a.mp4'), is_cover: true }]),
      ).rejects.toThrow(/Only a photo/);
    });

    it('rejects a file already attached to a different record', async () => {
      repo.findByUrl.mockResolvedValue(makeRow({ hotel_id: 'other-hotel' }));
      await expect(
        service.prepare(asRepo(), 'hotels', [{ url: url('acme/hotels/1-a.jpg') }], 'h1'),
      ).rejects.toThrow(/another record/);
    });

    it('rejects a file that is no longer on disk', async () => {
      jest.spyOn(storage, 'exists').mockResolvedValue(false);
      await expect(
        service.prepare(asRepo(), 'hotels', [{ url: url('acme/hotels/1-a.jpg') }]),
      ).rejects.toThrow(/upload it again/);
    });
  });

  describe('sync', () => {
    it('does nothing when prepared is undefined', async () => {
      await service.sync(asRepo(), 'h1', undefined);
      expect(repo.findForParent).not.toHaveBeenCalled();
    });

    it('soft-deletes removed rows, moves their file to deleted/, inserts new and updates changed', async () => {
      repo.findForParent.mockResolvedValue([
        makeRow({ id: 'keep', url: url('acme/hotels/1-a.jpg'), is_cover: true }),
        makeRow({ id: 'gone', url: url('acme/hotels/2-b.jpg'), is_cover: false, sort_order: 1 }),
      ]);

      await service.sync(asRepo(), 'h1', [
        {
          url: url('acme/hotels/3-c.jpg'),
          media_type: 'image',
          filename: '3-c.jpg',
          is_cover: true,
          sort_order: 0,
        },
        {
          url: url('acme/hotels/1-a.jpg'),
          media_type: 'image',
          filename: '1-a.jpg',
          is_cover: false,
          sort_order: 1,
        },
      ]);

      expect(repo.softDelete).toHaveBeenCalledWith('gone');
      expect(storage.move).toHaveBeenCalledWith(
        'acme/hotels/2-b.jpg',
        'deleted/acme/hotels/2-b.jpg',
      );
      expect(repo.update).toHaveBeenCalledWith('keep', { is_cover: false, sort_order: 1 });
      expect(repo.saveMany).toHaveBeenCalledWith([
        expect.objectContaining({ hotel_id: 'h1', url: url('acme/hotels/3-c.jpg') }),
      ]);
    });
  });

  describe('trashUrl', () => {
    it('ignores logos, tickets and foreign URLs', async () => {
      await service.trashUrl(url('acme/logo.png'));
      await service.trashUrl(url('acme/unused/t.pdf'));
      await service.trashUrl('https://cdn.example.com/x.jpg');
      await service.trashUrl(null);
      expect(storage.move).not.toHaveBeenCalled();
    });

    it('never throws when the move fails', async () => {
      jest.spyOn(storage, 'move').mockRejectedValue(new Error('disk'));
      await expect(service.trashUrl(url('acme/suppliers/1-a.jpg'))).resolves.toBeUndefined();
    });
  });

  describe('prepareSinglePhoto', () => {
    it('maps empty to null and keeps an unchanged legacy URL', async () => {
      await expect(service.prepareSinglePhoto(repo, 'suppliers', '')).resolves.toBeNull();
      await expect(
        service.prepareSinglePhoto(repo, 'suppliers', url('acme/old.jpg'), url('acme/old.jpg')),
      ).resolves.toBe(url('acme/old.jpg'));
    });

    it("rejects another tenant's photo and videos (cross-tenant)", async () => {
      await expect(
        service.prepareSinglePhoto(repo, 'suppliers', url('victim/suppliers/1-a.jpg')),
      ).rejects.toThrow(BadRequestException);
      await expect(
        service.prepareSinglePhoto(repo, 'suppliers', url('acme/suppliers/1-a.mp4')),
      ).rejects.toThrow(/Only a photo/);
    });

    it('accepts a photo uploaded to the suppliers folder', async () => {
      await expect(
        service.prepareSinglePhoto(repo, 'suppliers', url('acme/suppliers/1-a.jpg')),
      ).resolves.toBe(url('acme/suppliers/1-a.jpg'));
    });
  });
});
