import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ShoppingListsService } from '../../../src/modules/shopping-lists/shopping-lists.service.js';
import { NotFoundError } from '../../../src/common/errors.js';

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

  const mockListsRepo = {
    list: vi.fn(),
    findById: vi.fn(),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
    addItem: vi.fn(),
    updateItem: vi.fn(),
    removeItem: vi.fn(),
  };

  const mockGapService = {
    calculateGap: vi.fn(),
  };

  const mockPantryService = {
    create: vi.fn(),
  };

  const mockProductsRepo = {
    findById: vi.fn(),
  };

  const mockPricesService = {
    estimatePrice: vi.fn(),
    recordPrice: vi.fn(),
    compareStores: vi.fn(),
  };

  const mockMealPlanRepo = {
    findById: vi.fn(),
    update: vi.fn(),
  };

  beforeEach(() => {
    vi.clearAllMocks();
    service = new ShoppingListsService({
      shoppingListsRepository: mockListsRepo as any,
      shoppingGapService: mockGapService as any,
      pantryService: mockPantryService as any,
      productsRepository: mockProductsRepo as any,
      pricesService: mockPricesService as any,
      mealPlanRepository: mockMealPlanRepo as any,
    });
  });

  describe('create', () => {
    it('populates initial estimates and auto-generates internal tracking UUIDs', async () => {
      mockProductsRepo.findById.mockResolvedValue({ category: 'produce' });
      mockPricesService.estimatePrice.mockResolvedValue(5);
      mockListsRepo.create.mockImplementation(arg => arg);

      const result = await service.create(
        {
          name: 'Weekly run',
          items: [{ productId: 'p1', quantity: 1, unit: 'g' as any }],
        },
        'hh1',
        'u1'
      );

      expect(result.items).toHaveLength(1);
      expect(result.items[0].id).toBeDefined();
      expect(result.items[0].estimatedPrice).toBe(5);
      expect(result.totalEstimatedCost).toBe(5);
    });

    it('handles missing items and retains explicit categories without hitting product info', async () => {
      mockListsRepo.create.mockImplementation(arg => Promise.resolve(arg));
      const resEmpty = await service.create({ name: 'Empty' }, 'hh1', 'u1');
      expect(resEmpty.items).toEqual([]);

      mockProductsRepo.findById.mockResolvedValue({ category: 'meat' });
      mockPricesService.estimatePrice.mockResolvedValue(10);

      const resCategory = await service.create(
        {
          name: 'Overridden',
          items: [{ productId: 'p1', quantity: 1, category: 'bakery', unit: 'g' as any }],
        },
        'hh1',
        'u1'
      );
      expect(resCategory.items[0].category).toBe('bakery');
    });

    it('handles missing product info or estimates gracefully during creation', async () => {
      mockProductsRepo.findById.mockResolvedValue(null);
      mockPricesService.estimatePrice.mockResolvedValue(null);
      mockListsRepo.create.mockImplementation(arg => arg);

      const result = await service.create(
        {
          name: 'Minimal run',
          items: [{ productId: 'p1', quantity: 1, unit: 'g' as any }],
        },
        'hh1',
        'u1'
      );

      expect(result.items[0].category).toBeUndefined();
      expect(result.items[0].estimatedPrice).toBeUndefined();
      expect(result.totalEstimatedCost).toBeUndefined();
    });

    it('handles items without productId gracefully during creation', async () => {
      mockListsRepo.create.mockImplementation(arg => arg);
      const result = await service.create(
        {
          name: 'Custom run',
          items: [{ customName: 'Bread', quantity: 1, unit: 'pcs' as any }],
        },
        'hh1',
        'u1'
      );
      expect(result.items[0].customName).toBe('Bread');
    });
  });

  describe('list', () => {
    it('delegates to repository', async () => {
      mockListsRepo.list.mockResolvedValue(['listA']);
      const res = await service.list('hh1');
      expect(mockListsRepo.list).toHaveBeenCalledWith('hh1');
      expect(res).toEqual(['listA']);
    });
  });

  describe('getById', () => {
    it('throws NotFoundError if repository returns null', async () => {
      mockListsRepo.findById.mockResolvedValue(null);
      await expect(service.getById('list1', 'hh1')).rejects.toThrow(NotFoundError);
    });
  });

  describe('update', () => {
    it('updates shopping list properties and returns it', async () => {
      mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
      mockListsRepo.update.mockResolvedValue({ _id: 'list1', name: 'New Name' });
      const res = await service.update('list1', 'hh1', { name: 'New Name' });
      expect(mockListsRepo.update).toHaveBeenCalledWith('list1', 'hh1', { name: 'New Name' });
      expect(res.name).toBe('New Name');
    });

    it('throws NotFoundError if update returns null', async () => {
      mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
      mockListsRepo.update.mockResolvedValue(null);
      await expect(service.update('list1', 'hh1', { name: 'New Name' })).rejects.toThrow(NotFoundError);
    });
  });

  describe('addItem', () => {
    it('hydrates single product pricing and pushes to list repository', async () => {
      mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
      mockProductsRepo.findById.mockResolvedValue({ category: 'meat' });
      mockPricesService.estimatePrice.mockResolvedValue(10);
      mockListsRepo.addItem.mockResolvedValue({ _id: 'list1' });

      const res = await service.addItem('list1', 'hh1', {
        productId: 'prodA',
        quantity: 1,
        unit: 'g' as any,
      });

      expect(mockListsRepo.addItem).toHaveBeenCalledWith(
        'list1',
        'hh1',
        expect.objectContaining({
          productId: 'prodA',
          estimatedPrice: 10,
          category: 'meat',
        })
      );
      expect(res.addedItem.id).toBeDefined();
    });

    it('skips product info fetch and adds custom items', async () => {
      mockListsRepo.addItem.mockImplementation((id, hh, data) => Promise.resolve({ _id: id }));
      const res = await service.addItem('list1', 'hh1', {
        customName: 'Custom item',
        quantity: 1,
        unit: 'g' as any,
      });
      expect(res.addedItem.customName).toBe('Custom item');
      expect(res.addedItem.productId).toBeUndefined();
    });

    it('throws NotFoundError if list update returns null when adding item', async () => {
      mockListsRepo.addItem.mockResolvedValue(null);
      await expect(
        service.addItem('list1', 'hh1', { customName: 'Nonsense', quantity: 1, unit: 'g' as any })
      ).rejects.toThrow(NotFoundError);
    });

    it('handles missing product info or estimates gracefully during addItem', async () => {
      mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
      mockProductsRepo.findById.mockResolvedValue(null);
      mockPricesService.estimatePrice.mockResolvedValue(null);
      mockListsRepo.addItem.mockResolvedValue({ _id: 'list1' });

      const res = await service.addItem('list1', 'hh1', {
        productId: 'prodUnknown',
        quantity: 1,
        unit: 'g' as any,
        category: 'explicit',
      });

      expect(res.addedItem.category).toBe('explicit');
      expect(res.addedItem.estimatedPrice).toBeUndefined();
    });
  });

  describe('updateItem', () => {
    it('injects correct checked timestamps on check-off state mutations', async () => {
      mockListsRepo.updateItem.mockResolvedValue({});

      await service.updateItem('list1', 'hh1', 'itemA', { checked: true }, 'userIdX');

      expect(mockListsRepo.updateItem).toHaveBeenCalledWith(
        'list1',
        'hh1',
        'itemA',
        expect.objectContaining({
          checked: true,
          checkedBy: 'userIdX',
          checkedAt: expect.any(Date),
        })
      );
    });

    it('wipes timestamps if unchecking an item', async () => {
      mockListsRepo.updateItem.mockImplementation((a, b, c, d) => Promise.resolve(d));
      const res = await service.updateItem('list1', 'hh1', 'itemA', { checked: false }, 'userIdX');
      expect(res.checkedAt).toBeUndefined();
      expect(res.checkedBy).toBeUndefined();
    });

    it('throws NotFoundError if item/list is missing on update', async () => {
      mockListsRepo.updateItem.mockResolvedValue(null);
      await expect(service.updateItem('list1', 'hh1', 'itemA', { checked: true }, 'u1')).rejects.toThrow(NotFoundError);
    });

    it('does not touch timestamps if checked is not provided', async () => {
      mockListsRepo.updateItem.mockResolvedValue({});
      await service.updateItem('list1', 'hh1', 'itemA', { quantity: 5 } as any, 'u1');
      expect(mockListsRepo.updateItem).toHaveBeenCalledWith(
        'list1',
        'hh1',
        'itemA',
        { quantity: 5 }
      );
    });
  });

  describe('removeItem', () => {
    it('removes item from list repository', async () => {
      mockListsRepo.removeItem.mockResolvedValue({ _id: 'list1' });
      await service.removeItem('list1', 'hh1', 'itemA');
      expect(mockListsRepo.removeItem).toHaveBeenCalledWith('list1', 'hh1', 'itemA');
    });

    it('throws NotFoundError if list not found on removeItem', async () => {
      mockListsRepo.removeItem.mockResolvedValue(null);
      await expect(service.removeItem('list1', 'hh1', 'itemA')).rejects.toThrow(NotFoundError);
    });
  });

  describe('delete', () => {
    it('deletes the shopping list', async () => {
      mockListsRepo.findById.mockResolvedValue({ _id: 'list1' });
      mockListsRepo.delete.mockResolvedValue(true);
      await service.delete('list1', 'hh1');
      expect(mockListsRepo.delete).toHaveBeenCalledWith('list1', 'hh1');
    });
  });

  describe('createFromMealPlan', () => {
    it('runs shopping gap report and populates distinct grocery array linked back to source plan', async () => {
      mockMealPlanRepo.findById.mockResolvedValue({ _id: 'mp1', weekStartDate: '2026-05-18' });
      mockGapService.calculateGap.mockResolvedValue({
        missingItems: [
          { productId: 'gapProd', missingQuantity: 5, unit: 'g', category: 'dairy' }
        ]
      });
      mockPricesService.estimatePrice.mockResolvedValue(2);
      mockListsRepo.create.mockResolvedValue({ _id: 'newList1' });

      const res = await service.createFromMealPlan('mp1', 'hh1', 'userIdZ');

      expect(mockGapService.calculateGap).toHaveBeenCalledWith('hh1', 'mp1');
      expect(mockListsRepo.create).toHaveBeenCalledWith(
        expect.objectContaining({
          mealPlanId: 'mp1',
          items: [
            expect.objectContaining({
              productId: 'gapProd',
              quantity: 5,
              estimatedPrice: 2,
            })
          ]
        })
      );
      // Assert link-back invocation
      expect(mockMealPlanRepo.update).toHaveBeenCalledWith('mp1', 'hh1', {
        shoppingListId: 'newList1',
      });
    });

    it('throws NotFoundError if plan is not found', async () => {
      mockMealPlanRepo.findById.mockResolvedValue(null);
      await expect(service.createFromMealPlan('mpMissing', 'hh1', 'u1')).rejects.toThrow(NotFoundError);
    });

    it('handles missing estimated prices when creating from plan', async () => {
      mockMealPlanRepo.findById.mockResolvedValue({ _id: 'mp2', weekStartDate: '2026-05-18' });
      mockGapService.calculateGap.mockResolvedValue({
        missingItems: [{ productId: 'gapProd2', missingQuantity: 3, unit: 'g', category: 'produce' }]
      });

      mockPricesService.estimatePrice.mockResolvedValue(null);
      mockListsRepo.create.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'newList2' }));

      const res = await service.createFromMealPlan('mp2', 'hh1', 'u1');
      expect(res.items[0].estimatedPrice).toBeUndefined();
    });
  });

  describe('syncCheckedToPantry', () => {
    it('iterates checked items, creating pantry items and recording actual prices in ledger', async () => {
      const mockList = {
        _id: 'list1',
        preferredStoreId: 'storeA',
        items: [
          {
            id: 'itmA',
            productId: 'p1',
            checked: true,
            addedToPantry: false,
            quantity: 2,
            unit: 'g',
            actualPrice: 15.50,
          }
        ]
      };
      mockListsRepo.findById.mockResolvedValue(mockList);

      const summary = await service.syncCheckedToPantry('list1', 'hh1', 'userAlpha');

      // 1. Verify pantry promotion
      expect(mockPantryService.create).toHaveBeenCalledWith(
        expect.objectContaining({
          productId: 'p1',
          quantity: 2,
          purchasePrice: 15.50,
          storeId: 'storeA',
        }),
        'hh1',
        'userAlpha'
      );

      // 2. Verify point-in-time ledger price logging
      expect(mockPricesService.recordPrice).toHaveBeenCalledWith(
        expect.objectContaining({
          productId: 'p1',
          price: 15.50,
          storeId: 'storeA',
        }),
        'hh1',
        'userAlpha'
      );

      // 3. Verify completion bit toggled in list subdocument
      expect(mockListsRepo.updateItem).toHaveBeenCalledWith('list1', 'hh1', 'itmA', {
        addedToPantry: true,
      });

      expect(summary.addedCount).toBe(1);
      expect(summary.pricesLogged).toBe(1);
    });

    it('handles item-specific stores and skips pricing logs when no store identifier exists', async () => {
      const mockList = {
        _id: 'list2',
        items: [
          {
            id: 'itmB',
            productId: 'p2',
            checked: true,
            addedToPantry: false,
            quantity: 1,
            actualPrice: 10.00,
            storeId: 'itemStoreB',
          },
          {
            id: 'itmC',
            productId: 'p3',
            checked: true,
            addedToPantry: false,
            quantity: 1,
            actualPrice: 5.00,
          }
        ]
      };
      mockListsRepo.findById.mockResolvedValue(mockList);

      const summary = await service.syncCheckedToPantry('list2', 'hh1', 'userAlpha');

      expect(mockPricesService.recordPrice).toHaveBeenCalledTimes(1);
      expect(mockPricesService.recordPrice).toHaveBeenCalledWith(
        expect.objectContaining({
          productId: 'p2',
          price: 10.00,
          storeId: 'itemStoreB',
        }),
        'hh1',
        'userAlpha'
      );
      expect(summary.addedCount).toBe(2);
      expect(summary.pricesLogged).toBe(1);
    });
  });

  describe('getStoreComparison', () => {
    it('collates individual store deviation lists to rank optimized single store trips', async () => {
      mockListsRepo.findById.mockResolvedValue({
        items: [{ productId: 'p1' }]
      });
      mockPricesService.compareStores.mockResolvedValue([
        { storeId: 'sA', storeName: 'Walmart', latestPrice: 10 },
        { storeId: 'sB', storeName: 'Whole Foods', latestPrice: 18 },
      ]);

      const comparison = await service.getStoreComparison('list1', 'hh1');
      expect(comparison.singleStoreOptions).toHaveLength(2);
      expect(comparison.singleStoreOptions[0].storeName).toBe('Walmart');
      expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(10);
    });

    it('handles missing items in comparison', async () => {
      mockListsRepo.findById.mockResolvedValue({
        items: [{ productId: 'p1' }, { productId: 'p2' }]
      });
      // Store only has p1, p2 is missing
      mockPricesService.compareStores.mockImplementation(async (id) => {
        if (id === 'p1') return [{ storeId: 'sA', storeName: 'Walmart', latestPrice: 10 }];
        return [];
      });

      const comparison = await service.getStoreComparison('list1', 'hh1');
      expect(comparison.singleStoreOptions[0].itemsMissing).toContain('p2');
    });

    it('covers sorting tie breakers and default store name fallbacks', async () => {
      mockListsRepo.findById.mockResolvedValue({
        items: [{ productId: 'p1' }]
      });

      mockPricesService.compareStores.mockResolvedValue([
        { storeId: 'sA', storeName: '', latestPrice: 10 },
        { storeId: 'sB', storeName: 'Cheaper Store', latestPrice: 5 },
      ]);

      const result = await service.getStoreComparison('list1', 'hh1');
      expect(result.singleStoreOptions).toHaveLength(2);

      expect(result.singleStoreOptions[0].storeId).toBe('sB');
      expect(result.singleStoreOptions[1].storeName).toBe('Store');
    });

    it('handles stores offering pricing for multiple items in the basket', async () => {
      mockListsRepo.findById.mockResolvedValue({
        items: [{ productId: 'p1' }, { productId: 'p2' }]
      });

      mockPricesService.compareStores.mockImplementation(async (id) => {
        return [{ storeId: 'sC', storeName: 'Combo Store', latestPrice: id === 'p1' ? 5 : 7 }];
      });

      const comparison = await service.getStoreComparison('list1', 'hh1');
      expect(comparison.singleStoreOptions).toHaveLength(1);
      expect(comparison.singleStoreOptions[0].itemsCovered).toBe(2);
      expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(12);
    });
  });
});
