// src/routes/recipe.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import {
  createMockRecipe,
  createMockRecipeComment,
  createMockUserProfile,
} from '../tests/utils/mockFactories';
import { NotFoundError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';

// 1. Mock the Service Layer directly.
vi.mock('../services/db/index.db', () => ({
  recipeRepo: {
    getRecipesBySalePercentage: vi.fn(),
    getRecipesByMinSaleIngredients: vi.fn(),
    getRecipeById: vi.fn(),
    findRecipesByIngredientAndTag: vi.fn(),
    getRecipeComments: vi.fn(),
  },
}));

// Mock AI Service
vi.mock('../services/aiService.server', () => ({
  aiService: {
    generateRecipeSuggestion: vi.fn(),
  },
}));

// Mock Passport
vi.mock('../config/passport', () => ({
  default: {
    authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
      if (!req.user) {
        return res.status(401).json({ message: 'Unauthorized' });
      }
      next();
    }),
  },
}));

// Import the router and mocked DB AFTER all mocks are defined.
import recipeRouter from './recipe.routes';
import * as db from '../services/db/index.db';
import { aiService } from '../services/aiService.server';
import { mockLogger } from '../tests/utils/mockLogger';

// Mock the logger to keep test output clean
vi.mock('../services/logger.server', async () => ({
  // Use async import to avoid hoisting issues with mockLogger
  logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));

// Import the mocked db module to control its functions in tests
const expectLogger = expect.objectContaining({
  info: expect.any(Function),
  error: expect.any(Function),
});

describe('Recipe Routes (/api/recipes)', () => {
  const app = createTestApp({ router: recipeRouter, basePath: '/api/recipes' });

  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('GET /by-sale-percentage', () => {
    it('should return recipes based on sale percentage', async () => {
      const mockRecipes = [createMockRecipe({ recipe_id: 1, name: 'Pasta' })];
      vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockResolvedValue(mockRecipes);

      const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=75');

      expect(response.status).toBe(200);
      expect(response.body.data).toEqual(mockRecipes);
      expect(db.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(75, expectLogger);
    });

    it('should use the default minPercentage of 50 when none is provided', async () => {
      vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockResolvedValue([]);
      await supertest(app).get('/api/recipes/by-sale-percentage');
      expect(db.recipeRepo.getRecipesBySalePercentage).toHaveBeenCalledWith(50, expectLogger);
    });

    it('should return 500 if the database call fails', async () => {
      const dbError = new Error('DB Error');
      vi.mocked(db.recipeRepo.getRecipesBySalePercentage).mockRejectedValue(dbError);
      const response = await supertest(app).get('/api/recipes/by-sale-percentage');
      expect(response.status).toBe(500);
      expect(response.body.error.message).toBe('DB Error');
      expect(mockLogger.error).toHaveBeenCalledWith(
        { error: dbError },
        'Error fetching recipes in /api/recipes/by-sale-percentage:',
      );
    });

    it('should return 400 for an invalid minPercentage', async () => {
      const response = await supertest(app).get(
        '/api/recipes/by-sale-percentage?minPercentage=101',
      );
      expect(response.status).toBe(400);
      expect(response.body.error.details[0].message).toContain('Too big');
    });
  });

  describe('GET /by-sale-ingredients', () => {
    it('should return recipes with default minIngredients', async () => {
      vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockResolvedValue([]);
      const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
      expect(response.status).toBe(200);
      expect(db.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(3, expectLogger);
    });

    it('should use provided minIngredients query parameter', async () => {
      vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockResolvedValue([]);
      await supertest(app).get('/api/recipes/by-sale-ingredients?minIngredients=5');
      expect(db.recipeRepo.getRecipesByMinSaleIngredients).toHaveBeenCalledWith(5, expectLogger);
    });

    it('should return 500 if the database call fails', async () => {
      const dbError = new Error('DB Error');
      vi.mocked(db.recipeRepo.getRecipesByMinSaleIngredients).mockRejectedValue(dbError);
      const response = await supertest(app).get('/api/recipes/by-sale-ingredients');
      expect(response.status).toBe(500);
      expect(response.body.error.message).toBe('DB Error');
      expect(mockLogger.error).toHaveBeenCalledWith(
        { error: dbError },
        'Error fetching recipes in /api/recipes/by-sale-ingredients:',
      );
    });

    it('should return 400 for an invalid minIngredients', async () => {
      const response = await supertest(app).get(
        '/api/recipes/by-sale-ingredients?minIngredients=abc',
      );
      expect(response.status).toBe(400);
      expect(response.body.error.details[0].message).toContain('received NaN');
    });
  });

  describe('GET /by-ingredient-and-tag', () => {
    it('should return recipes for a given ingredient and tag', async () => {
      const mockRecipes = [createMockRecipe({ recipe_id: 2, name: 'Chicken Tacos' })];
      vi.mocked(db.recipeRepo.findRecipesByIngredientAndTag).mockResolvedValue(mockRecipes);

      const response = await supertest(app).get(
        '/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick',
      );

      expect(response.status).toBe(200);
      expect(response.body.data).toEqual(mockRecipes);
    });

    it('should return 500 if the database call fails', async () => {
      const dbError = new Error('DB Error');
      vi.mocked(db.recipeRepo.findRecipesByIngredientAndTag).mockRejectedValue(dbError);
      const response = await supertest(app).get(
        '/api/recipes/by-ingredient-and-tag?ingredient=chicken&tag=quick',
      );
      expect(response.status).toBe(500);
      expect(response.body.error.message).toBe('DB Error');
      expect(mockLogger.error).toHaveBeenCalledWith(
        { error: dbError },
        'Error fetching recipes in /api/recipes/by-ingredient-and-tag:',
      );
    });

    it('should return 400 if required query parameters are missing', async () => {
      const response = await supertest(app).get(
        '/api/recipes/by-ingredient-and-tag?ingredient=chicken',
      );
      expect(response.status).toBe(400);
      expect(response.body.error.details[0].message).toBe('Query parameter "tag" is required.');
    });
  });

  describe('GET /:recipeId/comments', () => {
    it('should return comments for a specific recipe', async () => {
      const mockComments = [createMockRecipeComment({ recipe_id: 1, content: 'Great recipe!' })];
      vi.mocked(db.recipeRepo.getRecipeComments).mockResolvedValue(mockComments);

      const response = await supertest(app).get('/api/recipes/1/comments');

      expect(response.status).toBe(200);
      expect(response.body.data).toEqual(mockComments);
      expect(db.recipeRepo.getRecipeComments).toHaveBeenCalledWith(1, expectLogger);
    });

    it('should return an empty array if recipe has no comments', async () => {
      vi.mocked(db.recipeRepo.getRecipeComments).mockResolvedValue([]);
      const response = await supertest(app).get('/api/recipes/2/comments');
      expect(response.body.data).toEqual([]);
    });

    it('should return 500 if the database call fails', async () => {
      const dbError = new Error('DB Error');
      vi.mocked(db.recipeRepo.getRecipeComments).mockRejectedValue(dbError);
      const response = await supertest(app).get('/api/recipes/1/comments');
      expect(response.status).toBe(500);
      expect(response.body.error.message).toBe('DB Error');
      expect(mockLogger.error).toHaveBeenCalledWith(
        { error: dbError },
        `Error fetching comments for recipe ID 1:`,
      );
    });

    it('should return 400 for an invalid recipeId', async () => {
      const response = await supertest(app).get('/api/recipes/abc/comments');
      expect(response.status).toBe(400);
      expect(response.body.error.details[0].message).toContain('received NaN');
    });
  });

  describe('GET /:recipeId', () => {
    it('should return a single recipe on success', async () => {
      const mockRecipe = createMockRecipe({ recipe_id: 456, name: 'Specific Recipe' });
      vi.mocked(db.recipeRepo.getRecipeById).mockResolvedValue(mockRecipe);

      const response = await supertest(app).get('/api/recipes/456');

      expect(response.status).toBe(200);
      expect(response.body.data).toEqual(mockRecipe);
      expect(db.recipeRepo.getRecipeById).toHaveBeenCalledWith(456, expectLogger);
    });

    it('should return 404 if the recipe is not found', async () => {
      const notFoundError = new NotFoundError('Recipe not found');
      vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(notFoundError);
      const response = await supertest(app).get('/api/recipes/999');
      expect(response.status).toBe(404);
      expect(response.body.error.message).toContain('not found');
      expect(mockLogger.error).toHaveBeenCalledWith(
        { error: notFoundError },
        `Error fetching recipe ID 999:`,
      );
    });

    it('should return 500 if the database call fails', async () => {
      const dbError = new Error('DB Error');
      vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(dbError);
      const response = await supertest(app).get('/api/recipes/456');
      expect(response.status).toBe(500);
      expect(response.body.error.message).toBe('DB Error');
      expect(mockLogger.error).toHaveBeenCalledWith(
        { error: dbError },
        `Error fetching recipe ID 456:`,
      );
    });

    it('should return 400 for an invalid recipeId', async () => {
      const response = await supertest(app).get('/api/recipes/abc');
      expect(response.status).toBe(400);
      expect(response.body.error.details[0].message).toContain('received NaN');
    });
  });

  describe('POST /suggest', () => {
    const mockUser = createMockUserProfile({ user: { user_id: 'user-123' } });
    const authApp = createTestApp({
      router: recipeRouter,
      basePath: '/api/recipes',
      authenticatedUser: mockUser,
    });

    it('should return a recipe suggestion', async () => {
      const ingredients = ['chicken', 'rice'];
      const mockSuggestion = 'Chicken and Rice Casserole...';
      vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(mockSuggestion);

      const response = await supertest(authApp).post('/api/recipes/suggest').send({ ingredients });

      expect(response.status).toBe(200);
      expect(response.body.data).toEqual({ suggestion: mockSuggestion });
      expect(aiService.generateRecipeSuggestion).toHaveBeenCalledWith(ingredients, expectLogger);
    });

    it('should return 503 if AI service returns null', async () => {
      vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue(null);

      const response = await supertest(authApp)
        .post('/api/recipes/suggest')
        .send({ ingredients: ['water'] });

      expect(response.status).toBe(503);
      expect(response.body.error.message).toContain('unavailable');
    });

    it('should return 400 if ingredients list is empty', async () => {
      const response = await supertest(authApp)
        .post('/api/recipes/suggest')
        .send({ ingredients: [] });

      expect(response.status).toBe(400);
      expect(response.body.error.details[0].message).toContain(
        'At least one ingredient is required',
      );
    });

    it('should return 401 if not authenticated', async () => {
      const unauthApp = createTestApp({ router: recipeRouter, basePath: '/api/recipes' });
      const response = await supertest(unauthApp)
        .post('/api/recipes/suggest')
        .send({ ingredients: ['chicken'] });

      expect(response.status).toBe(401);
    });

    it('should return 500 on service error', async () => {
      const error = new Error('AI Error');
      vi.mocked(aiService.generateRecipeSuggestion).mockRejectedValue(error);

      const response = await supertest(authApp)
        .post('/api/recipes/suggest')
        .send({ ingredients: ['chicken'] });

      expect(response.status).toBe(500);
      expect(mockLogger.error).toHaveBeenCalledWith(
        { error },
        'Error generating recipe suggestion',
      );
    });
  });

  describe('Rate Limiting on /suggest', () => {
    const mockUser = createMockUserProfile({ user: { user_id: 'rate-limit-user' } });
    const authApp = createTestApp({
      router: recipeRouter,
      basePath: '/api/recipes',
      authenticatedUser: mockUser,
    });

    it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
      // Arrange
      const maxRequests = 20; // Limit is 20 per 15 mins
      const ingredients = ['chicken', 'rice'];
      vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue('A tasty suggestion');

      // Act: Make maxRequests calls
      for (let i = 0; i < maxRequests; i++) {
        const response = await supertest(authApp)
          .post('/api/recipes/suggest')
          .set('X-Test-Rate-Limit-Enable', 'true')
          .send({ ingredients });
        expect(response.status).not.toBe(429);
      }

      // Act: Make one more call
      const blockedResponse = await supertest(authApp)
        .post('/api/recipes/suggest')
        .set('X-Test-Rate-Limit-Enable', 'true')
        .send({ ingredients });

      // Assert
      expect(blockedResponse.status).toBe(429);
      expect(blockedResponse.text).toContain('Too many AI generation requests');
    });

    it('should NOT block requests when the opt-in header is not sent', async () => {
      const maxRequests = 22;
      const ingredients = ['beef', 'potatoes'];
      vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue('Another suggestion');

      for (let i = 0; i < maxRequests; i++) {
        const response = await supertest(authApp)
          .post('/api/recipes/suggest')
          .send({ ingredients });
        expect(response.status).not.toBe(429);
      }
    });
  });

  describe('Rate Limiting on Public Routes', () => {
    it('should apply publicReadLimiter to GET /:recipeId', async () => {
      vi.mocked(db.recipeRepo.getRecipeById).mockResolvedValue(createMockRecipe({}));
      const response = await supertest(app)
        .get('/api/recipes/1')
        .set('X-Test-Rate-Limit-Enable', 'true');

      expect(response.status).toBe(200);
      expect(response.headers).toHaveProperty('ratelimit-limit');
      expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
    });
  });
});
