import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RecipesService } from '../../../src/modules/recipes/recipes.service.js';
import { NotFoundError, BadRequestError } from '../../../src/common/errors.js';

const makeProduct = (id: string, servingUnit = 'g', servingSize = 100) => ({
  _id: { toString: () => id },
  householdId: 'hh1',
  name: 'Test Product',
  servingSize,
  servingUnit,
  densityGPerMl: undefined as number | undefined,
  nutrition: { calories: 200, protein: 20, carbs: 10, fat: 8 },
});

const makeRecipe = (id = 'recipe-1') => ({
  _id: { toString: () => id },
  householdId: 'hh1',
  name: 'Test Recipe',
  servings: 2,
  ingredients: [
    {
      productId: 'p1',
      productName: 'Chicken',
      quantity: 200,
      unit: 'g',
      isOptional: false,
      nutritionContribution: { calories: 400, protein: 40, carbs: 0, fat: 16 },
    },
  ],
  steps: [],
  tags: [],
  isFavorite: false,
  totalNutrition: { calories: 400, protein: 40, carbs: 0, fat: 16 },
  perServingNutrition: { calories: 200, protein: 20, carbs: 0, fat: 8 },
  warnings: [],
  createdBy: 'user-1',
  createdAt: new Date().toISOString(),
  updatedAt: new Date().toISOString(),
});

describe(RecipesService.name, () => {
  const mockRecipesRepo = {
    findByHousehold: vi.fn(),
    findById: vi.fn(),
    findByProductId: vi.fn(),
    findAllByProductId: vi.fn(),
    create: vi.fn(),
    update: vi.fn(),
    softDelete: vi.fn(),
  };

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

  const mockLlmProvider = {
    extractNutrition: vi.fn(),
    parseRecipe: vi.fn(),
    parseRecipeFromUrl: vi.fn(),
    parseReceipt: vi.fn(),
    suggestMealPlan: vi.fn(),
    parseNaturalLanguage: vi.fn(),
  };

  let service: RecipesService;

  beforeEach(() => {
    vi.clearAllMocks();
    service = new RecipesService({
      recipesRepository: mockRecipesRepo as never,
      productsRepository: mockProductsRepo as never,
      llmProvider: mockLlmProvider as never,
    });
  });

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

      const result = await service.list('hh1', { limit: 20 });
      expect(mockRecipesRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
      expect(result).toEqual(expected);
    });
  });

  describe('getById', () => {
    it('returns recipe when found', async () => {
      const recipe = makeRecipe();
      mockRecipesRepo.findById.mockResolvedValue(recipe);

      const result = await service.getById('recipe-1', 'hh1');
      expect(result).toEqual(recipe);
    });

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

  describe('create', () => {
    it('normalizes metric ingredients and calculates nutrition', async () => {
      const product = makeProduct('p1');
      mockProductsRepo.findByIds.mockResolvedValue([product]);
      mockRecipesRepo.create.mockResolvedValue(makeRecipe());

      await service.create(
        {
          name: 'Test',
          servings: 2,
          ingredients: [
            {
              productId: 'p1',
              productName: 'Chicken',
              quantity: 200,
              unit: 'g',
              isOptional: false,
            },
          ],
          steps: [],
          tags: [],
          isFavorite: false,
        },
        'hh1',
        'user-1',
      );

      const [_, computed] = mockRecipesRepo.create.mock.calls[0]!;
      expect(computed.totalNutrition.calories).toBe(400); // 200g = 2× of 100g serving (200 kcal each)
      expect(computed.perServingNutrition.calories).toBe(200);
    });

    it('throws BadRequestError for missing density on cup → g conversion', async () => {
      const product = makeProduct('p1', 'g'); // g product, no density
      mockProductsRepo.findByIds.mockResolvedValue([product]);

      await expect(
        service.create(
          {
            name: 'Test',
            servings: 1,
            ingredients: [
              {
                productId: 'p1',
                productName: 'Sugar',
                quantity: 1,
                unit: 'cup',
                isOptional: false,
              },
            ],
            steps: [],
            tags: [],
            isFavorite: false,
          },
          'hh1',
          'user-1',
        ),
      ).rejects.toThrow(BadRequestError);
    });

    it('throws NotFoundError for unknown product', async () => {
      mockProductsRepo.findByIds.mockResolvedValue([]);

      await expect(
        service.create(
          {
            name: 'Test',
            servings: 1,
            ingredients: [
              {
                productId: 'unknown',
                productName: 'X',
                quantity: 100,
                unit: 'g',
                isOptional: false,
              },
            ],
            steps: [],
            tags: [],
            isFavorite: false,
          },
          'hh1',
          'user-1',
        ),
      ).rejects.toThrow(NotFoundError);
    });
  });

  describe('delete', () => {
    it('soft-deletes recipe', async () => {
      const recipe = makeRecipe();
      mockRecipesRepo.findById.mockResolvedValue(recipe);
      mockRecipesRepo.softDelete.mockResolvedValue(recipe);

      const result = await service.delete('recipe-1', 'hh1');
      expect(mockRecipesRepo.softDelete).toHaveBeenCalledWith('recipe-1', 'hh1');
      expect(result).toEqual(recipe);
    });

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

    it('throws NotFoundError when softDelete returns null', async () => {
      mockRecipesRepo.findById.mockResolvedValue(makeRecipe());
      mockRecipesRepo.softDelete.mockResolvedValue(null);
      await expect(service.delete('recipe-1', 'hh1')).rejects.toThrow(NotFoundError);
    });
  });

  describe('scale', () => {
    it('returns scaled ingredient quantities and recalculated nutrition', async () => {
      const recipe = makeRecipe();
      mockRecipesRepo.findById.mockResolvedValue(recipe);
      mockProductsRepo.findByIds.mockResolvedValue([makeProduct('p1')]);

      const result = await service.scale('recipe-1', 'hh1', { targetServings: 4 });

      expect(result.servings).toBe(4);
      // 200g × (4/2) = 400g
      expect(result.ingredients[0]!.quantity).toBe(400);
      expect(result.totalNutrition.calories).toBe(800);
    });
  });

  describe('importFromText', () => {
    it('returns available:false when LLM returns null', async () => {
      mockLlmProvider.parseRecipe.mockResolvedValue(null);
      const result = await service.importFromText('some text', 'hh1');
      expect(result).toEqual({ available: false });
    });

    it('returns draft when LLM returns a recipe', async () => {
      const draft = { name: 'Pasta', servings: 4, ingredients: [], steps: [] };
      mockLlmProvider.parseRecipe.mockResolvedValue(draft);
      const result = await service.importFromText('pasta recipe', 'hh1');
      expect(result).toEqual({ available: true, draft });
    });
  });

  describe('importFromUrl', () => {
    it('returns available:false when LLM returns null', async () => {
      mockLlmProvider.parseRecipeFromUrl.mockResolvedValue(null);
      const result = await service.importFromUrl('https://example.com/recipe', 'hh1');
      expect(result).toEqual({ available: false });
    });

    it('returns draft when LLM returns a recipe', async () => {
      const draft = { name: 'Soup', servings: 2, ingredients: [], steps: [] };
      mockLlmProvider.parseRecipeFromUrl.mockResolvedValue(draft);
      const result = await service.importFromUrl('https://example.com', 'hh1');
      expect(result).toEqual({ available: true, draft });
    });
  });

  describe('update', () => {
    it('updates metadata without recalculating if no ingredients/servings changed', async () => {
      const recipe = makeRecipe();
      mockRecipesRepo.findById.mockResolvedValue(recipe);
      mockRecipesRepo.update.mockResolvedValue({ ...recipe, name: 'Renamed' });

      const result = await service.update('recipe-1', 'hh1', { name: 'Renamed' });
      expect(result.name).toBe('Renamed');
      expect(mockProductsRepo.findByIds).not.toHaveBeenCalled();
    });

    it('recalculates nutrition when ingredients change', async () => {
      const recipe = makeRecipe();
      const product = makeProduct('p1');
      mockRecipesRepo.findById.mockResolvedValue(recipe);
      mockProductsRepo.findByIds.mockResolvedValue([product]);
      mockRecipesRepo.update.mockResolvedValue(recipe);

      await service.update('recipe-1', 'hh1', {
        ingredients: [
          {
            productId: 'p1',
            productName: 'Chicken',
            quantity: 300,
            unit: 'g',
            isOptional: false,
          },
        ],
      });

      expect(mockProductsRepo.findByIds).toHaveBeenCalled();
      expect(mockRecipesRepo.update).toHaveBeenCalled();
    });

    it('recalculates nutrition when only servings change', async () => {
      const recipe = makeRecipe();
      const product = makeProduct('p1');
      mockRecipesRepo.findById.mockResolvedValue(recipe);
      mockProductsRepo.findByIds.mockResolvedValue([product]);
      mockRecipesRepo.update.mockResolvedValue(recipe);

      await service.update('recipe-1', 'hh1', { servings: 4 });

      expect(mockProductsRepo.findByIds).toHaveBeenCalled();
    });

    it('throws NotFoundError when update returns null', async () => {
      mockRecipesRepo.findById.mockResolvedValue(makeRecipe());
      mockRecipesRepo.update.mockResolvedValue(null);

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

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

      const result = await service.findByProduct('hh1', 'p1', { limit: 20 });
      expect(mockRecipesRepo.findByProductId).toHaveBeenCalledWith('hh1', 'p1', { limit: 20 });
      expect(result).toEqual(expected);
    });
  });

  describe('recalculateForProduct', () => {
    it('recalculates all recipes containing the product', async () => {
      const recipe = makeRecipe();
      const product = makeProduct('p1');
      mockRecipesRepo.findAllByProductId.mockResolvedValue([recipe]);
      mockProductsRepo.findByIds.mockResolvedValue([product]);
      mockRecipesRepo.update.mockResolvedValue(recipe);

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

      expect(mockRecipesRepo.findAllByProductId).toHaveBeenCalledWith('hh1', 'p1');
      expect(mockRecipesRepo.update).toHaveBeenCalledTimes(1);
    });

    it('does nothing when no recipes contain the product', async () => {
      mockRecipesRepo.findAllByProductId.mockResolvedValue([]);
      await service.recalculateForProduct('hh1', 'p1');
      expect(mockRecipesRepo.update).not.toHaveBeenCalled();
    });
  });
});
