import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProductsService } from '../../../src/modules/products/products.service.js';
import { NotFoundError, ConflictError } from '../../../src/common/errors.js';
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';

const mockRepo = {
  findByHousehold: vi.fn(),
  findById: vi.fn(),
  findByIds: vi.fn(),
  findByBarcode: vi.fn(),
  findDuplicate: vi.fn(),
  create: vi.fn(),
  update: vi.fn(),
  softDelete: vi.fn(),
  bulkCreate: vi.fn(),
};

function makeProduct(overrides: Record<string, unknown> = {}) {
  return {
    _id: 'p1',
    householdId: 'hh1',
    name: 'Chicken Breast',
    category: ProductCategory.MEAT,
    servingSize: 100,
    servingUnit: ServingUnit.GRAMS,
    nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
    tags: [],
    source: ProductSource.MANUAL,
    createdBy: 'u1',
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    ...overrides,
  };
}

const createData = {
  name: 'Chicken Breast',
  category: ProductCategory.MEAT,
  servingSize: 100,
  servingUnit: ServingUnit.GRAMS,
  nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
  tags: [],
  source: ProductSource.MANUAL,
};

describe(ProductsService.name, () => {
  let service: ProductsService;

  beforeEach(() => {
    vi.clearAllMocks();
    service = new ProductsService({ productsRepository: mockRepo as never });
  });

  describe('list', () => {
    it('delegates to repository', async () => {
      const expected = { data: [], pagination: { cursor: null, hasMore: false } };
      mockRepo.findByHousehold.mockResolvedValue(expected);

      const result = await service.list('hh1', { limit: 20 });

      expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
      expect(result).toEqual(expected);
    });
  });

  describe('getById', () => {
    it('returns product when found', async () => {
      const product = makeProduct();
      mockRepo.findById.mockResolvedValue(product);

      const result = await service.getById('p1', 'hh1');
      expect(result).toEqual(product);
    });

    it('throws NotFoundError when product not found', async () => {
      mockRepo.findById.mockResolvedValue(null);

      await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
    });
  });

  describe('create', () => {
    it('creates product when no barcode conflict and no duplicate', async () => {
      mockRepo.findByBarcode.mockResolvedValue(null);
      mockRepo.findDuplicate.mockResolvedValue(null);
      mockRepo.create.mockResolvedValue(makeProduct());

      const result = await service.create(createData, 'hh1', 'u1');

      expect(mockRepo.create).toHaveBeenCalledWith(
        expect.objectContaining({ name: 'Chicken Breast', householdId: 'hh1', createdBy: 'u1' }),
      );
      expect(result._id).toBe('p1');
    });

    it('does not check barcode when none provided', async () => {
      mockRepo.findDuplicate.mockResolvedValue(null);
      mockRepo.create.mockResolvedValue(makeProduct());

      await service.create(createData, 'hh1', 'u1');

      expect(mockRepo.findByBarcode).not.toHaveBeenCalled();
    });

    it('throws ConflictError when barcode already exists', async () => {
      mockRepo.findByBarcode.mockResolvedValue(makeProduct({ barcode: '1234567890' }));

      await expect(
        service.create({ ...createData, barcode: '1234567890' }, 'hh1', 'u1'),
      ).rejects.toThrow(ConflictError);
    });

    it('throws ConflictError when duplicate name+brand exists', async () => {
      mockRepo.findByBarcode.mockResolvedValue(null);
      mockRepo.findDuplicate.mockResolvedValue(makeProduct());

      await expect(service.create(createData, 'hh1', 'u1')).rejects.toThrow(ConflictError);
    });

    it('uses ProductSource.MANUAL as default source', async () => {
      const dataWithoutSource = { ...createData };
      delete (dataWithoutSource as Partial<typeof createData>).source;
      mockRepo.findDuplicate.mockResolvedValue(null);
      mockRepo.create.mockResolvedValue(makeProduct());

      await service.create(dataWithoutSource, 'hh1', 'u1');

      expect(mockRepo.create).toHaveBeenCalledWith(
        expect.objectContaining({ source: ProductSource.MANUAL }),
      );
    });
  });

  describe('update', () => {
    it('updates product successfully', async () => {
      const product = makeProduct();
      mockRepo.findById.mockResolvedValue(product);
      mockRepo.findByBarcode.mockResolvedValue(null);
      mockRepo.findDuplicate.mockResolvedValue(null);
      mockRepo.update.mockResolvedValue({ ...product, name: 'Updated' });

      const result = await service.update('p1', 'hh1', { name: 'Updated' });

      expect(result.name).toBe('Updated');
    });

    it('throws NotFoundError when product does not exist', async () => {
      mockRepo.findById.mockResolvedValue(null);

      await expect(service.update('missing', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
    });

    it('throws ConflictError when barcode belongs to another product', async () => {
      mockRepo.findById.mockResolvedValue(makeProduct());
      mockRepo.findByBarcode.mockResolvedValue(makeProduct({ _id: 'p2' }));

      await expect(service.update('p1', 'hh1', { barcode: '1234567890' })).rejects.toThrow(
        ConflictError,
      );
    });

    it('does not throw barcode conflict when barcode belongs to same product', async () => {
      const product = makeProduct({ barcode: '1234567890' });
      mockRepo.findById.mockResolvedValue(product);
      mockRepo.findByBarcode.mockResolvedValue(product);
      mockRepo.findDuplicate.mockResolvedValue(null);
      mockRepo.update.mockResolvedValue(product);

      await expect(service.update('p1', 'hh1', { barcode: '1234567890' })).resolves.toBeDefined();
    });

    it('throws ConflictError when name+brand already taken by another', async () => {
      const product = makeProduct();
      mockRepo.findById.mockResolvedValue(product);
      mockRepo.findByBarcode.mockResolvedValue(null);
      mockRepo.findDuplicate.mockResolvedValue(makeProduct({ _id: 'p2' }));

      await expect(service.update('p1', 'hh1', { name: 'Chicken Breast' })).rejects.toThrow(
        ConflictError,
      );
    });

    it('throws NotFoundError when update returns null', async () => {
      const product = makeProduct();
      mockRepo.findById.mockResolvedValue(product);
      mockRepo.findByBarcode.mockResolvedValue(null);
      mockRepo.findDuplicate.mockResolvedValue(null);
      mockRepo.update.mockResolvedValue(null);

      await expect(service.update('p1', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
    });

    it('uses current brand when data.brand is not provided in dedup check', async () => {
      const product = makeProduct({ brand: 'BrandA' });
      mockRepo.findById
        .mockResolvedValueOnce(product) // getById call
        .mockResolvedValueOnce(product); // second findById call in update
      mockRepo.findByBarcode.mockResolvedValue(null);
      mockRepo.findDuplicate.mockResolvedValue(null);
      mockRepo.update.mockResolvedValue(product);

      await service.update('p1', 'hh1', { name: 'New Name' });

      expect(mockRepo.findDuplicate).toHaveBeenCalledWith('hh1', 'New Name', 'BrandA', 'p1');
    });
  });

  describe('delete', () => {
    it('soft-deletes the product', async () => {
      const product = makeProduct();
      mockRepo.findById.mockResolvedValue(product);
      mockRepo.softDelete.mockResolvedValue({ ...product, deletedAt: new Date() });

      await service.delete('p1', 'hh1');

      expect(mockRepo.softDelete).toHaveBeenCalledWith('p1', 'hh1');
    });

    it('throws NotFoundError when product does not exist', async () => {
      mockRepo.findById.mockResolvedValue(null);

      await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
    });

    it('throws NotFoundError when softDelete returns null', async () => {
      mockRepo.findById.mockResolvedValue(makeProduct());
      mockRepo.softDelete.mockResolvedValue(null);

      await expect(service.delete('p1', 'hh1')).rejects.toThrow(NotFoundError);
    });
  });

  describe('importProducts', () => {
    it('imports products skipping duplicates', async () => {
      const { source: _s, ...importData } = createData;
      const items = [
        { ...importData, name: 'Item A' },
        { ...importData, name: 'Item B', barcode: '111' },
        { ...importData, name: 'Item C' },
      ];
      // Item B has a barcode collision
      mockRepo.findByBarcode.mockResolvedValueOnce(makeProduct()); // Item B barcode exists
      mockRepo.findDuplicate
        .mockResolvedValueOnce(null) // Item A ok
        .mockResolvedValueOnce(makeProduct()); // Item C duplicate
      mockRepo.bulkCreate.mockResolvedValue([]);

      const result = await service.importProducts('hh1', 'u1', items as never);

      expect(result.imported).toBe(1); // Item A
      expect(result.skipped).toBe(2); // Item B (barcode), Item C (duplicate)
      expect(result.errors).toHaveLength(0);
      expect(mockRepo.bulkCreate).toHaveBeenCalledWith(
        'hh1',
        expect.arrayContaining([
          expect.objectContaining({ name: 'Item A', source: ProductSource.IMPORT }),
        ]),
      );
    });

    it('does not call bulkCreate when all items are skipped', async () => {
      mockRepo.findDuplicate.mockResolvedValue(makeProduct());

      const result = await service.importProducts('hh1', 'u1', [createData]);

      expect(result.imported).toBe(0);
      expect(result.skipped).toBe(1);
      expect(mockRepo.bulkCreate).not.toHaveBeenCalled();
    });

    it('respects provided source over IMPORT default', async () => {
      mockRepo.findDuplicate.mockResolvedValue(null);
      mockRepo.bulkCreate.mockResolvedValue([]);

      await service.importProducts('hh1', 'u1', [
        { ...createData, source: ProductSource.BARCODE_LOOKUP },
      ]);

      expect(mockRepo.bulkCreate).toHaveBeenCalledWith(
        'hh1',
        expect.arrayContaining([expect.objectContaining({ source: ProductSource.BARCODE_LOOKUP })]),
      );
    });

    it('records error when repo throws during item processing', async () => {
      mockRepo.findByBarcode.mockRejectedValue(new Error('DB error'));
      mockRepo.bulkCreate.mockResolvedValue([]);

      const result = await service.importProducts('hh1', 'u1', [{ ...createData, barcode: '111' }]);

      expect(result.imported).toBe(0);
      expect(result.skipped).toBe(0);
      expect(result.errors).toHaveLength(1);
      expect(result.errors[0]).toMatchObject({ row: 1, message: 'Validation error' });
    });
  });
});
