import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PantryService } from '../../../src/modules/pantry/pantry.service.js';
import { NotFoundError, BadRequestError } from '../../../src/common/errors.js';
import { ItemStatus } from '@meshitrack/shared';

const mockPantryRepo = {
  findByHousehold: vi.fn(),
  findById: vi.fn(),
  findExpiringSoon: vi.fn(),
  findActiveByHousehold: vi.fn(),
  create: vi.fn(),
  update: vi.fn(),
  updateFreshness: vi.fn(),
  delete: vi.fn(),
  getWasteStats: vi.fn(),
  getTopWastedProducts: vi.fn(),
  findByIds: vi.fn(),
  bulkUpdateStatus: vi.fn(),
};

const mockFreshnessRulesRepo = {
  findApplicableRule: vi.fn(),
};

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

function makeItem(overrides: Record<string, unknown> = {}) {
  return {
    _id: { toString: () => 'item-1' },
    householdId: 'hh1',
    productId: 'p1',
    productName: 'Milk',
    storageLocation: 'fridge',
    quantity: 1,
    unit: 'piece',
    purchaseDate: new Date('2024-01-01').toISOString(),
    status: ItemStatus.SEALED,
    freshnessEstimate: {
      estimatedExpiryDate: new Date('2024-01-15').toISOString(),
      daysRemaining: 14,
      urgency: 'fresh',
      source: 'rule',
    },
    createdBy: 'user-1',
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
    ...overrides,
  };
}

function makeProduct() {
  return {
    _id: 'p1',
    householdId: 'hh1',
    name: 'Milk',
    category: 'dairy',
    servingSize: 250,
    servingUnit: 'ml',
    nutrition: { calories: 60, protein: 3, carbs: 5, fat: 3 },
  };
}

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

  beforeEach(() => {
    vi.clearAllMocks();
    service = new PantryService({
      pantryRepository: mockPantryRepo as never,
      freshnessRulesRepository: mockFreshnessRulesRepo as never,
      productsRepository: mockProductsRepo as never,
    });
  });

  describe('list', () => {
    it('delegates to repository', async () => {
      const expected = { data: [], pagination: { cursor: null, hasMore: false } };
      mockPantryRepo.findByHousehold.mockResolvedValue(expected);
      const result = await service.list('hh1', { limit: 20 });
      expect(result).toEqual(expected);
    });
  });

  describe('getById', () => {
    it('returns item when found', async () => {
      const item = makeItem();
      mockPantryRepo.findById.mockResolvedValue(item);
      const result = await service.getById('item-1', 'hh1');
      expect(result).toEqual(item);
    });

    it('throws NotFoundError when not found', async () => {
      mockPantryRepo.findById.mockResolvedValue(null);
      await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
    });
  });

  describe('create', () => {
    it('creates a pantry item', async () => {
      const product = makeProduct();
      mockProductsRepo.findById.mockResolvedValue(product);
      mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
        shelfLifeDays: 14,
        openedLifeDays: 7,
      });
      mockPantryRepo.create.mockResolvedValue(makeItem());

      const result = await service.create(
        {
          productId: 'p1',
          storageLocation: 'fridge' as never,
          quantity: 1,
          unit: 'piece' as never,
        },
        'hh1',
        'user-1',
      );

      expect(mockPantryRepo.create).toHaveBeenCalled();
      expect(result).toBeDefined();
    });

    it('creates item with all optional fields', async () => {
      mockProductsRepo.findById.mockResolvedValue(makeProduct());
      mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
      mockPantryRepo.create.mockResolvedValue(makeItem());

      await service.create(
        {
          productId: 'p1',
          storageLocation: 'fridge' as never,
          quantity: 2,
          unit: 'piece' as never,
          purchaseDate: '2024-01-01T00:00:00Z',
          expirationDate: '2024-02-01T00:00:00Z',
          notes: 'Organic',
          purchasePrice: 4.99,
          storeId: 's1',
        },
        'hh1',
        'user-1',
      );

      expect(mockPantryRepo.create).toHaveBeenCalled();
    });

    it('throws NotFoundError when product not found', async () => {
      mockProductsRepo.findById.mockResolvedValue(null);
      await expect(
        service.create(
          {
            productId: 'missing',
            storageLocation: 'fridge' as never,
            quantity: 1,
            unit: 'piece' as never,
          },
          'hh1',
          'user-1',
        ),
      ).rejects.toThrow(NotFoundError);
    });
  });

  describe('update', () => {
    it('updates and returns item', async () => {
      const item = makeItem();
      mockPantryRepo.findById.mockResolvedValue(item);
      mockPantryRepo.update.mockResolvedValue({ ...item, quantity: 3 });

      const result = await service.update('item-1', 'hh1', { quantity: 3 });
      expect((result as Record<string, unknown>).quantity).toBe(3);
    });

    it('throws NotFoundError when update returns null', async () => {
      mockPantryRepo.findById.mockResolvedValue(makeItem());
      mockPantryRepo.update.mockResolvedValue(null);
      await expect(service.update('item-1', 'hh1', { quantity: 3 })).rejects.toThrow(NotFoundError);
    });
  });

  describe('transition', () => {
    it('transitions from sealed to opened', async () => {
      const item = makeItem({ status: ItemStatus.SEALED });
      mockPantryRepo.findById.mockResolvedValue(item);
      mockProductsRepo.findById.mockResolvedValue(makeProduct());
      mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
        shelfLifeDays: 14,
        openedLifeDays: 7,
      });
      mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.OPENED });

      const result = await service.transition('item-1', 'hh1', { status: 'opened' as never });
      expect((result as Record<string, unknown>).status).toBe(ItemStatus.OPENED);
    });

    it('transitions from sealed to consumed', async () => {
      const item = makeItem({ status: ItemStatus.SEALED });
      mockPantryRepo.findById.mockResolvedValue(item);
      mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.CONSUMED });

      const result = await service.transition('item-1', 'hh1', { status: 'consumed' as never });
      expect((result as Record<string, unknown>).status).toBe(ItemStatus.CONSUMED);
    });

    it('transitions from sealed to discarded', async () => {
      const item = makeItem({ status: ItemStatus.SEALED });
      mockPantryRepo.findById.mockResolvedValue(item);
      mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.DISCARDED });

      const result = await service.transition('item-1', 'hh1', { status: 'discarded' as never });
      expect((result as Record<string, unknown>).status).toBe(ItemStatus.DISCARDED);
    });

    it('transitions from opened to prepared', async () => {
      const item = makeItem({ status: ItemStatus.OPENED });
      mockPantryRepo.findById.mockResolvedValue(item);
      mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.PREPARED });

      const result = await service.transition('item-1', 'hh1', { status: 'prepared' as never });
      expect((result as Record<string, unknown>).status).toBe(ItemStatus.PREPARED);
    });

    it('rejects invalid transition', async () => {
      const item = makeItem({ status: ItemStatus.CONSUMED });
      mockPantryRepo.findById.mockResolvedValue(item);
      await expect(
        service.transition('item-1', 'hh1', { status: 'opened' as never }),
      ).rejects.toThrow(BadRequestError);
    });

    it('includes notes and date in transition', async () => {
      const item = makeItem({ status: ItemStatus.SEALED });
      mockPantryRepo.findById.mockResolvedValue(item);
      mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.CONSUMED });

      await service.transition('item-1', 'hh1', {
        status: 'consumed' as never,
        date: '2024-01-10T12:00:00Z',
        notes: 'Used in cooking',
      });

      expect(mockPantryRepo.update).toHaveBeenCalled();
    });

    it('throws NotFoundError when update returns null', async () => {
      const item = makeItem({ status: ItemStatus.SEALED });
      mockPantryRepo.findById.mockResolvedValue(item);
      mockPantryRepo.update.mockResolvedValue(null);
      await expect(
        service.transition('item-1', 'hh1', { status: 'consumed' as never }),
      ).rejects.toThrow(NotFoundError);
    });

    it('recalculates freshness when opening and product not found', async () => {
      const item = makeItem({ status: ItemStatus.SEALED });
      mockPantryRepo.findById.mockResolvedValue(item);
      mockProductsRepo.findById.mockResolvedValue(null);
      mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
      mockPantryRepo.update.mockResolvedValue({ ...item, status: ItemStatus.OPENED });

      await service.transition('item-1', 'hh1', { status: 'opened' as never });
      expect(mockFreshnessRulesRepo.findApplicableRule).toHaveBeenCalledWith(
        'hh1',
        'other',
        'fridge',
      );
    });
  });

  describe('batchTransition', () => {
    it('transitions valid items', async () => {
      const items = [
        makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.SEALED }),
        makeItem({ _id: { toString: () => 'id2' }, status: ItemStatus.OPENED }),
      ];
      mockPantryRepo.findByIds.mockResolvedValue(items);
      mockPantryRepo.bulkUpdateStatus.mockResolvedValue(2);

      const result = await service.batchTransition('hh1', {
        itemIds: ['id1', 'id2'],
        status: 'consumed' as never,
      });

      expect(result.transitioned).toBe(2);
      expect(result.failed).toBe(0);
    });

    it('skips items with invalid transitions', async () => {
      const items = [makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.CONSUMED })];
      mockPantryRepo.findByIds.mockResolvedValue(items);

      const result = await service.batchTransition('hh1', {
        itemIds: ['id1'],
        status: 'consumed' as never,
      });

      expect(result.transitioned).toBe(0);
      expect(result.failed).toBe(1);
    });

    it('passes date and notes as extra', async () => {
      const items = [makeItem({ _id: { toString: () => 'id1' }, status: ItemStatus.SEALED })];
      mockPantryRepo.findByIds.mockResolvedValue(items);
      mockPantryRepo.bulkUpdateStatus.mockResolvedValue(1);

      await service.batchTransition('hh1', {
        itemIds: ['id1'],
        status: 'discarded' as never,
        date: '2024-01-10T00:00:00Z',
        notes: 'Expired',
      });

      expect(mockPantryRepo.bulkUpdateStatus).toHaveBeenCalled();
    });
  });

  describe('getExpiringSoon', () => {
    it('delegates to repository', async () => {
      const expected = { data: [], pagination: { cursor: null, hasMore: false } };
      mockPantryRepo.findExpiringSoon.mockResolvedValue(expected);
      const result = await service.getExpiringSoon('hh1', { days: 7, limit: 20 });
      expect(result).toEqual(expected);
    });
  });

  describe('getWasteStats', () => {
    it('computes waste stats for a period', async () => {
      mockPantryRepo.getWasteStats.mockResolvedValue([{ totalConsumed: 8, totalDiscarded: 2 }]);
      mockPantryRepo.getTopWastedProducts.mockResolvedValue([
        { productId: 'p1', productName: 'Milk', count: 2 },
      ]);

      const result = await service.getWasteStats('hh1', { period: 'month' });

      expect(result.totalItemsConsumed).toBe(8);
      expect(result.totalItemsDiscarded).toBe(2);
      expect(result.wastePercentage).toBe(20);
      expect(result.topWastedProducts).toHaveLength(1);
    });

    it('handles no data', async () => {
      mockPantryRepo.getWasteStats.mockResolvedValue([]);
      mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);

      const result = await service.getWasteStats('hh1', { period: 'week' });

      expect(result.totalItemsConsumed).toBe(0);
      expect(result.totalItemsDiscarded).toBe(0);
      expect(result.wastePercentage).toBe(0);
    });

    it('handles quarter period', async () => {
      mockPantryRepo.getWasteStats.mockResolvedValue([]);
      mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);
      const result = await service.getWasteStats('hh1', { period: 'quarter' });
      expect(result.period.start).toBeDefined();
    });

    it('handles year period', async () => {
      mockPantryRepo.getWasteStats.mockResolvedValue([]);
      mockPantryRepo.getTopWastedProducts.mockResolvedValue([]);
      const result = await service.getWasteStats('hh1', { period: 'year' });
      expect(result.period.start).toBeDefined();
    });
  });

  describe('refreshAllFreshness', () => {
    it('refreshes all active items', async () => {
      const item = makeItem();
      mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
      mockProductsRepo.findById.mockResolvedValue(makeProduct());
      mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
        shelfLifeDays: 14,
        openedLifeDays: 7,
      });
      mockPantryRepo.updateFreshness.mockResolvedValue(undefined);

      await service.refreshAllFreshness('hh1');

      expect(mockPantryRepo.updateFreshness).toHaveBeenCalledTimes(1);
    });

    it('marks items as expired when urgency is expired', async () => {
      const item = makeItem({
        purchaseDate: new Date('2020-01-01').toISOString(),
      });
      mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
      mockProductsRepo.findById.mockResolvedValue(makeProduct());
      mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue({
        shelfLifeDays: 1,
        openedLifeDays: 1,
      });
      mockPantryRepo.updateFreshness.mockResolvedValue(undefined);

      await service.refreshAllFreshness('hh1');

      const updateCall = mockPantryRepo.updateFreshness.mock.calls[0];
      expect(updateCall?.[2]).toBe(ItemStatus.EXPIRED);
    });

    it('handles missing product gracefully', async () => {
      const item = makeItem();
      mockPantryRepo.findActiveByHousehold.mockResolvedValue([item]);
      mockProductsRepo.findById.mockResolvedValue(null);
      mockFreshnessRulesRepo.findApplicableRule.mockResolvedValue(null);
      mockPantryRepo.updateFreshness.mockResolvedValue(undefined);

      await service.refreshAllFreshness('hh1');

      expect(mockFreshnessRulesRepo.findApplicableRule).toHaveBeenCalledWith(
        'hh1',
        'other',
        'fridge',
      );
    });
  });

  describe('delete', () => {
    it('deletes item', async () => {
      mockPantryRepo.findById.mockResolvedValue(makeItem());
      mockPantryRepo.delete.mockResolvedValue(makeItem());
      const result = await service.delete('item-1', 'hh1');
      expect(result).toBeDefined();
    });

    it('throws NotFoundError when not found', async () => {
      mockPantryRepo.findById.mockResolvedValue(null);
      await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
    });
  });
});
