import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { CatalogService } from './catalog.service';
import { PrismaService } from '../../prisma/prisma.service';

// ─── Mock factory helpers ─────────────────────────────────────────────────────

const makeCategory = (overrides: Record<string, unknown> = {}) => ({
  id: 'cat-1',
  clubId: 'club-1',
  name: 'Group Classes',
  sortOrder: 0,
  createdAt: new Date('2024-01-01'),
  updatedAt: new Date('2024-01-01'),
  _count: { services: 3 },
  ...overrides,
});

const makeService = (overrides: Record<string, unknown> = {}) => ({
  id: 'svc-1',
  clubId: 'club-1',
  name: 'Personal Training',
  description: 'One-on-one session',
  price: 3000,
  duration: 60,
  categoryId: 'cat-1',
  isActive: true,
  createdAt: new Date('2024-01-01'),
  updatedAt: new Date('2024-01-01'),
  category: { id: 'cat-1', name: 'Group Classes' },
  _count: { packages: 2 },
  ...overrides,
});

const makePackage = (overrides: Record<string, unknown> = {}) => ({
  id: 'pkg-1',
  clubId: 'club-1',
  name: '10-session package',
  description: 'Ten personal training sessions',
  price: 25000,
  totalSessions: 10,
  validDays: 90,
  serviceId: 'svc-1',
  isActive: true,
  createdAt: new Date('2024-01-01'),
  updatedAt: new Date('2024-01-01'),
  service: { id: 'svc-1', name: 'Personal Training' },
  ...overrides,
});

// ─── Prisma mock ──────────────────────────────────────────────────────────────

const mockPrisma = {
  serviceCategory: {
    findMany: jest.fn(),
    findFirst: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    delete: jest.fn(),
    count: jest.fn(),
  },
  service: {
    findMany: jest.fn(),
    findFirst: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    delete: jest.fn(),
    count: jest.fn(),
  },
  package: {
    findMany: jest.fn(),
    findFirst: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    delete: jest.fn(),
    count: jest.fn(),
  },
};

// ─── Suite ────────────────────────────────────────────────────────────────────

describe('CatalogService', () => {
  let service: CatalogService;

  const CLUB_ID = 'club-1';

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        CatalogService,
        { provide: PrismaService, useValue: mockPrisma },
      ],
    }).compile();

    service = module.get<CatalogService>(CatalogService);
    jest.clearAllMocks();
  });

  // ─── Categories ────────────────────────────────────────────────────────────

  describe('Categories', () => {
    describe('findAllCategories', () => {
      it('returns paginated list without cursor when results fit in one page', async () => {
        const categories = [makeCategory({ id: 'cat-1' }), makeCategory({ id: 'cat-2' })];
        mockPrisma.serviceCategory.findMany.mockResolvedValue(categories);

        const result = await service.findAllCategories(CLUB_ID, { limit: 20 });

        expect(result.data).toHaveLength(2);
        expect(result.hasMore).toBe(false);
        expect(result.nextCursor).toBeNull();
        expect(mockPrisma.serviceCategory.findMany).toHaveBeenCalledWith(
          expect.objectContaining({
            where: { clubId: CLUB_ID },
            take: 21,
            orderBy: { sortOrder: 'asc' },
            include: { _count: { select: { services: true } } },
          }),
        );
      });

      it('sets hasMore and nextCursor correctly when more items exist', async () => {
        // Service fetches limit+1; if we get limit+1 back there are more pages.
        const categories = Array.from({ length: 3 }, (_, i) =>
          makeCategory({ id: `cat-${i + 1}` }),
        );
        mockPrisma.serviceCategory.findMany.mockResolvedValue(categories);

        const result = await service.findAllCategories(CLUB_ID, { limit: 2 });

        expect(result.hasMore).toBe(true);
        expect(result.data).toHaveLength(2);
        expect(result.nextCursor).toBe('cat-2');
      });

      it('passes cursor and skip when cursor is provided', async () => {
        mockPrisma.serviceCategory.findMany.mockResolvedValue([]);

        await service.findAllCategories(CLUB_ID, { cursor: 'cat-5', limit: 10 });

        expect(mockPrisma.serviceCategory.findMany).toHaveBeenCalledWith(
          expect.objectContaining({
            cursor: { id: 'cat-5' },
            skip: 1,
          }),
        );
      });
    });

    describe('findCategoryById', () => {
      it('returns the category when found', async () => {
        const category = makeCategory();
        mockPrisma.serviceCategory.findFirst.mockResolvedValue(category);

        const result = await service.findCategoryById(CLUB_ID, 'cat-1');

        expect(result).toEqual(category);
        expect(mockPrisma.serviceCategory.findFirst).toHaveBeenCalledWith(
          expect.objectContaining({ where: { id: 'cat-1', clubId: CLUB_ID } }),
        );
      });

      it('throws NotFoundException when category does not exist', async () => {
        mockPrisma.serviceCategory.findFirst.mockResolvedValue(null);

        await expect(service.findCategoryById(CLUB_ID, 'missing')).rejects.toThrow(
          new NotFoundException('Category with id missing not found'),
        );
      });
    });

    describe('createCategory', () => {
      it('creates a category with provided name and sortOrder', async () => {
        const created = makeCategory({ name: 'Yoga', sortOrder: 1 });
        mockPrisma.serviceCategory.create.mockResolvedValue(created);

        const result = await service.createCategory(CLUB_ID, { name: 'Yoga', sortOrder: 1 });

        expect(result).toEqual(created);
        expect(mockPrisma.serviceCategory.create).toHaveBeenCalledWith({
          data: { clubId: CLUB_ID, name: 'Yoga', sortOrder: 1 },
        });
      });

      it('defaults sortOrder to 0 when not provided', async () => {
        const created = makeCategory({ name: 'Pilates', sortOrder: 0 });
        mockPrisma.serviceCategory.create.mockResolvedValue(created);

        await service.createCategory(CLUB_ID, { name: 'Pilates' });

        expect(mockPrisma.serviceCategory.create).toHaveBeenCalledWith({
          data: { clubId: CLUB_ID, name: 'Pilates', sortOrder: 0 },
        });
      });
    });

    describe('updateCategory', () => {
      it('updates an existing category successfully', async () => {
        mockPrisma.serviceCategory.count.mockResolvedValue(1);
        const updated = makeCategory({ name: 'Updated Name' });
        mockPrisma.serviceCategory.update.mockResolvedValue(updated);

        const result = await service.updateCategory(CLUB_ID, 'cat-1', { name: 'Updated Name' });

        expect(result).toEqual(updated);
        expect(mockPrisma.serviceCategory.update).toHaveBeenCalledWith(
          expect.objectContaining({
            where: { id: 'cat-1' },
            data: { name: 'Updated Name' },
          }),
        );
      });

      it('throws NotFoundException when category to update does not exist', async () => {
        mockPrisma.serviceCategory.count.mockResolvedValue(0);

        await expect(
          service.updateCategory(CLUB_ID, 'missing', { name: 'X' }),
        ).rejects.toThrow(NotFoundException);

        expect(mockPrisma.serviceCategory.update).not.toHaveBeenCalled();
      });
    });

    describe('removeCategory', () => {
      it('deletes an existing category', async () => {
        mockPrisma.serviceCategory.count.mockResolvedValue(1);
        mockPrisma.serviceCategory.delete.mockResolvedValue(undefined);

        await service.removeCategory(CLUB_ID, 'cat-1');

        expect(mockPrisma.serviceCategory.delete).toHaveBeenCalledWith({
          where: { id: 'cat-1' },
        });
      });

      it('throws NotFoundException when category to delete does not exist', async () => {
        mockPrisma.serviceCategory.count.mockResolvedValue(0);

        await expect(service.removeCategory(CLUB_ID, 'missing')).rejects.toThrow(
          NotFoundException,
        );

        expect(mockPrisma.serviceCategory.delete).not.toHaveBeenCalled();
      });
    });
  });

  // ─── Services ─────────────────────────────────────────────────────────────

  describe('Services', () => {
    describe('findAllServices', () => {
      it('returns paginated list of services', async () => {
        const services = [makeService({ id: 'svc-1' }), makeService({ id: 'svc-2' })];
        mockPrisma.service.findMany.mockResolvedValue(services);

        const result = await service.findAllServices(CLUB_ID, { limit: 20 });

        expect(result.data).toHaveLength(2);
        expect(result.hasMore).toBe(false);
        expect(result.nextCursor).toBeNull();
      });

      it('sets hasMore and correct nextCursor when more items exist', async () => {
        const services = Array.from({ length: 3 }, (_, i) =>
          makeService({ id: `svc-${i + 1}` }),
        );
        mockPrisma.service.findMany.mockResolvedValue(services);

        const result = await service.findAllServices(CLUB_ID, { limit: 2 });

        expect(result.hasMore).toBe(true);
        expect(result.data).toHaveLength(2);
        expect(result.nextCursor).toBe('svc-2');
      });

      it('applies categoryId, search and isActive filters', async () => {
        mockPrisma.service.findMany.mockResolvedValue([]);

        await service.findAllServices(CLUB_ID, {
          limit: 10,
          categoryId: 'cat-1',
          search: 'yoga',
          isActive: true,
        });

        expect(mockPrisma.service.findMany).toHaveBeenCalledWith(
          expect.objectContaining({
            where: expect.objectContaining({
              clubId: CLUB_ID,
              categoryId: 'cat-1',
              name: { contains: 'yoga', mode: 'insensitive' },
              isActive: true,
            }),
          }),
        );
      });
    });

    describe('findServiceById', () => {
      it('returns the service when found', async () => {
        const svc = makeService();
        mockPrisma.service.findFirst.mockResolvedValue(svc);

        const result = await service.findServiceById(CLUB_ID, 'svc-1');

        expect(result).toEqual(svc);
        expect(mockPrisma.service.findFirst).toHaveBeenCalledWith(
          expect.objectContaining({ where: { id: 'svc-1', clubId: CLUB_ID } }),
        );
      });

      it('throws NotFoundException when service does not exist', async () => {
        mockPrisma.service.findFirst.mockResolvedValue(null);

        await expect(service.findServiceById(CLUB_ID, 'missing')).rejects.toThrow(
          new NotFoundException('Service with id missing not found'),
        );
      });
    });

    describe('createService', () => {
      it('creates a service without categoryId validation when categoryId is absent', async () => {
        const created = makeService({ categoryId: null, category: null });
        mockPrisma.service.create.mockResolvedValue(created);

        const dto = {
          name: 'Yoga',
          price: 1500,
          duration: 45,
        };

        const result = await service.createService(CLUB_ID, dto);

        expect(result).toEqual(created);
        expect(mockPrisma.serviceCategory.count).not.toHaveBeenCalled();
        expect(mockPrisma.service.create).toHaveBeenCalledWith(
          expect.objectContaining({
            data: expect.objectContaining({
              clubId: CLUB_ID,
              name: 'Yoga',
              price: 1500,
              duration: 45,
              isActive: true,
            }),
          }),
        );
      });

      it('validates categoryId exists before creating when categoryId is provided', async () => {
        mockPrisma.serviceCategory.count.mockResolvedValue(1);
        const created = makeService();
        mockPrisma.service.create.mockResolvedValue(created);

        await service.createService(CLUB_ID, {
          name: 'PT',
          price: 3000,
          duration: 60,
          categoryId: 'cat-1',
        });

        expect(mockPrisma.serviceCategory.count).toHaveBeenCalledWith({
          where: { id: 'cat-1', clubId: CLUB_ID },
        });
        expect(mockPrisma.service.create).toHaveBeenCalled();
      });

      it('throws NotFoundException when provided categoryId does not exist', async () => {
        mockPrisma.serviceCategory.count.mockResolvedValue(0);

        await expect(
          service.createService(CLUB_ID, {
            name: 'PT',
            price: 3000,
            duration: 60,
            categoryId: 'bad-cat',
          }),
        ).rejects.toThrow(NotFoundException);

        expect(mockPrisma.service.create).not.toHaveBeenCalled();
      });
    });

    describe('updateService', () => {
      it('updates an existing service', async () => {
        mockPrisma.service.count.mockResolvedValue(1);
        const updated = makeService({ name: 'Updated PT', price: 3500 });
        mockPrisma.service.update.mockResolvedValue(updated);

        const result = await service.updateService(CLUB_ID, 'svc-1', {
          name: 'Updated PT',
          price: 3500,
        });

        expect(result).toEqual(updated);
        expect(mockPrisma.service.update).toHaveBeenCalledWith(
          expect.objectContaining({
            where: { id: 'svc-1' },
            data: expect.objectContaining({ name: 'Updated PT', price: 3500 }),
          }),
        );
      });

      it('throws NotFoundException when service to update does not exist', async () => {
        mockPrisma.service.count.mockResolvedValue(0);

        await expect(
          service.updateService(CLUB_ID, 'missing', { name: 'X' }),
        ).rejects.toThrow(NotFoundException);

        expect(mockPrisma.service.update).not.toHaveBeenCalled();
      });

      it('validates new categoryId when it is included in the update dto', async () => {
        // First count = ensureServiceExists, second count = ensureCategoryExists
        mockPrisma.service.count.mockResolvedValue(1);
        mockPrisma.serviceCategory.count.mockResolvedValue(0);

        await expect(
          service.updateService(CLUB_ID, 'svc-1', { categoryId: 'bad-cat' }),
        ).rejects.toThrow(NotFoundException);

        expect(mockPrisma.service.update).not.toHaveBeenCalled();
      });
    });

    describe('removeService', () => {
      it('deletes an existing service', async () => {
        mockPrisma.service.count.mockResolvedValue(1);
        mockPrisma.service.delete.mockResolvedValue(undefined);

        await service.removeService(CLUB_ID, 'svc-1');

        expect(mockPrisma.service.delete).toHaveBeenCalledWith({ where: { id: 'svc-1' } });
      });

      it('throws NotFoundException when service to delete does not exist', async () => {
        mockPrisma.service.count.mockResolvedValue(0);

        await expect(service.removeService(CLUB_ID, 'missing')).rejects.toThrow(
          NotFoundException,
        );

        expect(mockPrisma.service.delete).not.toHaveBeenCalled();
      });
    });
  });

  // ─── Packages ─────────────────────────────────────────────────────────────

  describe('Packages', () => {
    describe('findAllPackages', () => {
      it('returns paginated list of packages', async () => {
        const packages = [makePackage({ id: 'pkg-1' }), makePackage({ id: 'pkg-2' })];
        mockPrisma.package.findMany.mockResolvedValue(packages);

        const result = await service.findAllPackages(CLUB_ID, { limit: 20 });

        expect(result.data).toHaveLength(2);
        expect(result.hasMore).toBe(false);
        expect(result.nextCursor).toBeNull();
        expect(mockPrisma.package.findMany).toHaveBeenCalledWith(
          expect.objectContaining({
            where: { clubId: CLUB_ID },
            take: 21,
            orderBy: { name: 'asc' },
            include: { service: { select: { id: true, name: true } } },
          }),
        );
      });

      it('sets hasMore and nextCursor when more items exist', async () => {
        const packages = Array.from({ length: 3 }, (_, i) =>
          makePackage({ id: `pkg-${i + 1}` }),
        );
        mockPrisma.package.findMany.mockResolvedValue(packages);

        const result = await service.findAllPackages(CLUB_ID, { limit: 2 });

        expect(result.hasMore).toBe(true);
        expect(result.data).toHaveLength(2);
        expect(result.nextCursor).toBe('pkg-2');
      });

      it('applies serviceId and isActive filters', async () => {
        mockPrisma.package.findMany.mockResolvedValue([]);

        await service.findAllPackages(CLUB_ID, {
          limit: 10,
          serviceId: 'svc-1',
          isActive: false,
        });

        expect(mockPrisma.package.findMany).toHaveBeenCalledWith(
          expect.objectContaining({
            where: expect.objectContaining({
              clubId: CLUB_ID,
              serviceId: 'svc-1',
              isActive: false,
            }),
          }),
        );
      });

      it('passes cursor and skip when cursor is provided', async () => {
        mockPrisma.package.findMany.mockResolvedValue([]);

        await service.findAllPackages(CLUB_ID, { cursor: 'pkg-5', limit: 10 });

        expect(mockPrisma.package.findMany).toHaveBeenCalledWith(
          expect.objectContaining({
            cursor: { id: 'pkg-5' },
            skip: 1,
          }),
        );
      });
    });

    describe('findPackageById', () => {
      it('returns the package when found', async () => {
        const pkg = makePackage();
        mockPrisma.package.findFirst.mockResolvedValue(pkg);

        const result = await service.findPackageById(CLUB_ID, 'pkg-1');

        expect(result).toEqual(pkg);
        expect(mockPrisma.package.findFirst).toHaveBeenCalledWith(
          expect.objectContaining({ where: { id: 'pkg-1', clubId: CLUB_ID } }),
        );
      });

      it('throws NotFoundException when package does not exist', async () => {
        mockPrisma.package.findFirst.mockResolvedValue(null);

        await expect(service.findPackageById(CLUB_ID, 'missing')).rejects.toThrow(
          new NotFoundException('Package with id missing not found'),
        );
      });
    });

    describe('createPackage', () => {
      it('creates a package after validating the service exists', async () => {
        mockPrisma.service.count.mockResolvedValue(1);
        const created = makePackage();
        mockPrisma.package.create.mockResolvedValue(created);

        const dto = {
          name: '10-session package',
          price: 25000,
          totalSessions: 10,
          validDays: 90,
          serviceId: 'svc-1',
        };

        const result = await service.createPackage(CLUB_ID, dto);

        expect(result).toEqual(created);
        expect(mockPrisma.service.count).toHaveBeenCalledWith({
          where: { id: 'svc-1', clubId: CLUB_ID },
        });
        expect(mockPrisma.package.create).toHaveBeenCalledWith(
          expect.objectContaining({
            data: expect.objectContaining({
              clubId: CLUB_ID,
              name: '10-session package',
              price: 25000,
              totalSessions: 10,
              validDays: 90,
              serviceId: 'svc-1',
              isActive: true,
            }),
          }),
        );
      });

      it('throws NotFoundException when the service for the package does not exist', async () => {
        mockPrisma.service.count.mockResolvedValue(0);

        await expect(
          service.createPackage(CLUB_ID, {
            name: 'Bad pkg',
            price: 100,
            totalSessions: 5,
            validDays: 30,
            serviceId: 'bad-svc',
          }),
        ).rejects.toThrow(NotFoundException);

        expect(mockPrisma.package.create).not.toHaveBeenCalled();
      });
    });

    describe('updatePackage', () => {
      it('updates an existing package', async () => {
        mockPrisma.package.count.mockResolvedValue(1);
        const updated = makePackage({ name: 'Premium 20', totalSessions: 20 });
        mockPrisma.package.update.mockResolvedValue(updated);

        const result = await service.updatePackage(CLUB_ID, 'pkg-1', {
          name: 'Premium 20',
          totalSessions: 20,
        });

        expect(result).toEqual(updated);
        expect(mockPrisma.package.update).toHaveBeenCalledWith(
          expect.objectContaining({
            where: { id: 'pkg-1' },
            data: expect.objectContaining({ name: 'Premium 20', totalSessions: 20 }),
          }),
        );
      });

      it('throws NotFoundException when package to update does not exist', async () => {
        mockPrisma.package.count.mockResolvedValue(0);

        await expect(
          service.updatePackage(CLUB_ID, 'missing', { name: 'X' }),
        ).rejects.toThrow(NotFoundException);

        expect(mockPrisma.package.update).not.toHaveBeenCalled();
      });

      it('validates new serviceId when it is included in the update dto', async () => {
        // First count = ensurePackageExists, second count = ensureServiceExists
        mockPrisma.package.count.mockResolvedValue(1);
        mockPrisma.service.count.mockResolvedValue(0);

        await expect(
          service.updatePackage(CLUB_ID, 'pkg-1', { serviceId: 'bad-svc' }),
        ).rejects.toThrow(NotFoundException);

        expect(mockPrisma.package.update).not.toHaveBeenCalled();
      });
    });

    describe('removePackage', () => {
      it('deletes an existing package', async () => {
        mockPrisma.package.count.mockResolvedValue(1);
        mockPrisma.package.delete.mockResolvedValue(undefined);

        await service.removePackage(CLUB_ID, 'pkg-1');

        expect(mockPrisma.package.delete).toHaveBeenCalledWith({ where: { id: 'pkg-1' } });
      });

      it('throws NotFoundException when package to delete does not exist', async () => {
        mockPrisma.package.count.mockResolvedValue(0);

        await expect(service.removePackage(CLUB_ID, 'missing')).rejects.toThrow(
          NotFoundException,
        );

        expect(mockPrisma.package.delete).not.toHaveBeenCalled();
      });
    });
  });
});
