// src/routes/receipt.routes.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { createTestApp } from '../tests/utils/createTestApp';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import receiptRouter from './receipt.routes';
import type { ReceiptStatus, ReceiptItemStatus } from '../types/expiry';
import { NotFoundError } from '../services/db/errors.db';

// Test state - must be declared before vi.mock calls that reference them
let mockUser: ReturnType<typeof createMockUserProfile> | null = null;
let mockFile: Express.Multer.File | null = null;

// Mock passport
vi.mock('../config/passport', () => ({
  default: {
    authenticate: vi.fn(() => (req: any, res: any, next: any) => {
      if (mockUser) {
        req.user = mockUser;
        next();
      } else {
        res.status(401).json({ success: false, error: { message: 'Unauthorized' } });
      }
    }),
    initialize: () => (req: any, res: any, next: any) => next(),
  },
}));

// Mock receipt service
vi.mock('../services/receiptService.server', () => ({
  getReceipts: vi.fn(),
  createReceipt: vi.fn(),
  getReceiptById: vi.fn(),
  deleteReceipt: vi.fn(),
  getReceiptItems: vi.fn(),
  updateReceiptItem: vi.fn(),
  getUnaddedItems: vi.fn(),
  getProcessingLogs: vi.fn(),
}));

// Mock expiry service
vi.mock('../services/expiryService.server', () => ({
  addItemsFromReceipt: vi.fn(),
}));

// Mock receipt queue
vi.mock('../services/queues.server', () => ({
  receiptQueue: {
    add: vi.fn(),
  },
}));

// Mock multer middleware
vi.mock('../middleware/multer.middleware', () => {
  return {
    createUploadMiddleware: vi.fn(() => ({
      single: vi.fn(() => (req: any, _res: any, next: any) => {
        // Simulate file upload by setting req.file
        if (mockFile) {
          req.file = mockFile;
        }
        // Multer also parses the body fields from multipart form data.
        // Since we're mocking multer, we need to ensure req.body is an object.
        // Supertest with .field() sends data as multipart which express.json() doesn't parse.
        // The actual field data won't be in req.body from supertest when multer is mocked,
        // so we leave req.body as-is (express.json() will have parsed JSON requests,
        // and for multipart we need to ensure body is at least an empty object).
        if (req.body === undefined) {
          req.body = {};
        }
        next();
      }),
    })),
    handleMulterError: vi.fn((err: any, _req: any, res: any, next: any) => {
      // Only handle multer-specific errors, pass others to the error handler
      if (err && err.name === 'MulterError') {
        return res.status(400).json({ success: false, error: { message: err.message } });
      }
      // Pass non-multer errors to the next error handler
      next(err);
    }),
  };
});

// Mock file upload middleware
vi.mock('../middleware/fileUpload.middleware', () => ({
  requireFileUpload: vi.fn(() => (req: any, res: any, next: any) => {
    if (!req.file) {
      return res.status(400).json({
        success: false,
        error: { message: 'File is required' },
      });
    }
    next();
  }),
}));

import * as receiptService from '../services/receiptService.server';
import * as expiryService from '../services/expiryService.server';
import { receiptQueue } from '../services/queues.server';

// Helper to create mock receipt (ReceiptScan type)
function createMockReceipt(overrides: { status?: ReceiptStatus; [key: string]: unknown } = {}) {
  return {
    receipt_id: 1,
    user_id: 'user-123',
    receipt_image_url: '/uploads/receipts/receipt-123.jpg',
    store_id: null,
    transaction_date: null,
    total_amount_cents: null,
    status: 'pending' as ReceiptStatus,
    raw_text: null,
    store_confidence: null,
    ocr_provider: null,
    error_details: null,
    retry_count: 0,
    ocr_confidence: null,
    currency: 'USD',
    created_at: '2024-01-15T10:00:00Z',
    processed_at: null,
    updated_at: '2024-01-15T10:00:00Z',
    ...overrides,
  };
}

// Helper to create mock receipt item (ReceiptItem type)
function createMockReceiptItem(
  overrides: { status?: ReceiptItemStatus; [key: string]: unknown } = {},
) {
  return {
    receipt_item_id: 1,
    receipt_id: 1,
    raw_item_description: 'MILK 2% 4L',
    quantity: 1,
    price_paid_cents: 599,
    master_item_id: null,
    product_id: null,
    status: 'unmatched' as ReceiptItemStatus,
    line_number: 1,
    match_confidence: null,
    is_discount: false,
    unit_price_cents: null,
    unit_type: null,
    added_to_pantry: false,
    pantry_item_id: null,
    upc_code: null,
    created_at: '2024-01-15T10:00:00Z',
    updated_at: '2024-01-15T10:00:00Z',
    ...overrides,
  };
}

// Helper to create mock processing log (ReceiptProcessingLogRecord type)
function createMockProcessingLog(overrides: Record<string, unknown> = {}) {
  return {
    log_id: 1,
    receipt_id: 1,
    processing_step: 'upload' as const,
    status: 'completed' as const,
    provider: null,
    duration_ms: null,
    tokens_used: null,
    cost_cents: null,
    input_data: null,
    output_data: null,
    error_message: null,
    created_at: '2024-01-15T10:00:00Z',
    ...overrides,
  };
}

describe('Receipt Routes', () => {
  let app: ReturnType<typeof createTestApp>;

  beforeEach(() => {
    vi.clearAllMocks();
    mockUser = createMockUserProfile();
    mockFile = null;
    app = createTestApp({
      router: receiptRouter,
      basePath: '/receipts',
      authenticatedUser: mockUser,
    });
  });

  afterEach(() => {
    vi.resetAllMocks();
    mockUser = null;
    mockFile = null;
  });

  describe('GET /receipts', () => {
    it('should return user receipts with default pagination', async () => {
      const mockReceipts = [createMockReceipt(), createMockReceipt({ receipt_id: 2 })];
      vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({
        receipts: mockReceipts,
        total: 2,
      });

      const response = await request(app).get('/receipts');

      expect(response.status).toBe(200);
      expect(response.body.success).toBe(true);
      expect(response.body.data.receipts).toHaveLength(2);
      expect(receiptService.getReceipts).toHaveBeenCalledWith(
        expect.objectContaining({
          user_id: mockUser!.user.user_id,
          limit: 50,
          offset: 0,
        }),
        expect.anything(),
      );
    });

    it('should support status filter', async () => {
      vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({
        receipts: [createMockReceipt({ status: 'completed' })],
        total: 1,
      });

      const response = await request(app).get('/receipts?status=completed');

      expect(response.status).toBe(200);
      expect(receiptService.getReceipts).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'completed' }),
        expect.anything(),
      );
    });

    it('should support store_id filter', async () => {
      vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({
        receipts: [createMockReceipt({ store_id: 5 })],
        total: 1,
      });

      const response = await request(app).get('/receipts?store_id=5');

      expect(response.status).toBe(200);
      expect(receiptService.getReceipts).toHaveBeenCalledWith(
        expect.objectContaining({ store_id: 5 }),
        expect.anything(),
      );
    });

    it('should support date range filter', async () => {
      vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({
        receipts: [],
        total: 0,
      });

      const response = await request(app).get('/receipts?from_date=2024-01-01&to_date=2024-01-31');

      expect(response.status).toBe(200);
      expect(receiptService.getReceipts).toHaveBeenCalledWith(
        expect.objectContaining({
          from_date: '2024-01-01',
          to_date: '2024-01-31',
        }),
        expect.anything(),
      );
    });

    it('should support pagination', async () => {
      vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({
        receipts: [],
        total: 100,
      });

      const response = await request(app).get('/receipts?limit=10&offset=20');

      expect(response.status).toBe(200);
      expect(receiptService.getReceipts).toHaveBeenCalledWith(
        expect.objectContaining({ limit: 10, offset: 20 }),
        expect.anything(),
      );
    });

    it('should reject invalid status', async () => {
      const response = await request(app).get('/receipts?status=invalid');

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

    it('should handle service error', async () => {
      vi.mocked(receiptService.getReceipts).mockRejectedValueOnce(new Error('DB error'));

      const response = await request(app).get('/receipts');

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

  describe('POST /receipts', () => {
    beforeEach(() => {
      mockFile = {
        fieldname: 'receipt',
        originalname: 'receipt.jpg',
        encoding: '7bit',
        mimetype: 'image/jpeg',
        destination: '/uploads/receipts',
        filename: 'receipt-123.jpg',
        path: '/uploads/receipts/receipt-123.jpg',
        size: 1024000,
      } as Express.Multer.File;
    });

    it('should upload receipt and queue for processing', async () => {
      const mockReceipt = createMockReceipt();
      vi.mocked(receiptService.createReceipt).mockResolvedValueOnce(mockReceipt);
      vi.mocked(receiptQueue.add).mockResolvedValueOnce({ id: 'job-123' } as any);

      // Send JSON body instead of form fields since multer is mocked and doesn't parse form data
      const response = await request(app)
        .post('/receipts')
        .send({ store_id: '1', transaction_date: '2024-01-15' });

      expect(response.status).toBe(201);
      expect(response.body.success).toBe(true);
      expect(response.body.data.receipt_id).toBe(1);
      expect(response.body.data.job_id).toBe('job-123');
      expect(receiptService.createReceipt).toHaveBeenCalledWith(
        mockUser!.user.user_id,
        '/uploads/receipts/receipt-123.jpg',
        expect.anything(),
        expect.objectContaining({
          storeId: 1,
          transactionDate: '2024-01-15',
        }),
      );
      expect(receiptQueue.add).toHaveBeenCalledWith(
        'process-receipt',
        expect.objectContaining({
          receiptId: 1,
          userId: mockUser!.user.user_id,
          imagePath: '/uploads/receipts/receipt-123.jpg',
        }),
        expect.objectContaining({
          jobId: 'receipt-1',
        }),
      );
    });

    it('should upload receipt without optional fields', async () => {
      const mockReceipt = createMockReceipt();
      vi.mocked(receiptService.createReceipt).mockResolvedValueOnce(mockReceipt);
      vi.mocked(receiptQueue.add).mockResolvedValueOnce({ id: 'job-456' } as any);

      const response = await request(app).post('/receipts');

      expect(response.status).toBe(201);
      expect(receiptService.createReceipt).toHaveBeenCalledWith(
        mockUser!.user.user_id,
        '/uploads/receipts/receipt-123.jpg',
        expect.anything(),
        expect.objectContaining({
          storeId: undefined,
          transactionDate: undefined,
        }),
      );
    });

    it('should reject request without file', async () => {
      mockFile = null;

      const response = await request(app).post('/receipts');

      expect(response.status).toBe(400);
      expect(response.body.error.message).toContain('File is required');
    });

    it('should handle service error', async () => {
      vi.mocked(receiptService.createReceipt).mockRejectedValueOnce(new Error('Storage error'));

      const response = await request(app).post('/receipts');

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

  describe('GET /receipts/:receiptId', () => {
    it('should return receipt with items', async () => {
      const mockReceipt = createMockReceipt();
      const mockItems = [createMockReceiptItem(), createMockReceiptItem({ receipt_item_id: 2 })];

      vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
      vi.mocked(receiptService.getReceiptItems).mockResolvedValueOnce(mockItems);

      const response = await request(app).get('/receipts/1');

      expect(response.status).toBe(200);
      expect(response.body.success).toBe(true);
      expect(response.body.data.receipt.receipt_id).toBe(1);
      expect(response.body.data.items).toHaveLength(2);
      expect(receiptService.getReceiptById).toHaveBeenCalledWith(
        1,
        mockUser!.user.user_id,
        expect.anything(),
      );
    });

    it('should return 404 for non-existent receipt', async () => {
      vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
        new NotFoundError('Receipt not found'),
      );

      const response = await request(app).get('/receipts/999');

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

    it('should reject invalid receipt ID', async () => {
      const response = await request(app).get('/receipts/invalid');

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

  describe('DELETE /receipts/:receiptId', () => {
    it('should delete receipt successfully', async () => {
      vi.mocked(receiptService.deleteReceipt).mockResolvedValueOnce(undefined);

      const response = await request(app).delete('/receipts/1');

      expect(response.status).toBe(204);
      expect(receiptService.deleteReceipt).toHaveBeenCalledWith(
        1,
        mockUser!.user.user_id,
        expect.anything(),
      );
    });

    it('should return 404 for non-existent receipt', async () => {
      vi.mocked(receiptService.deleteReceipt).mockRejectedValueOnce(
        new NotFoundError('Receipt not found'),
      );

      const response = await request(app).delete('/receipts/999');

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

  describe('POST /receipts/:receiptId/reprocess', () => {
    it('should queue receipt for reprocessing', async () => {
      const mockReceipt = createMockReceipt({ status: 'failed' });
      vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
      vi.mocked(receiptQueue.add).mockResolvedValueOnce({ id: 'reprocess-job-123' } as any);

      const response = await request(app).post('/receipts/1/reprocess');

      expect(response.status).toBe(200);
      expect(response.body.success).toBe(true);
      expect(response.body.data.message).toContain('reprocessing');
      expect(response.body.data.job_id).toBe('reprocess-job-123');
      expect(receiptQueue.add).toHaveBeenCalledWith(
        'process-receipt',
        expect.objectContaining({
          receiptId: 1,
          imagePath: mockReceipt.receipt_image_url,
        }),
        expect.objectContaining({
          jobId: expect.stringMatching(/^receipt-1-reprocess-\d+$/),
        }),
      );
    });

    it('should return 404 for non-existent receipt', async () => {
      vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
        new NotFoundError('Receipt not found'),
      );

      const response = await request(app).post('/receipts/999/reprocess');

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

  describe('GET /receipts/:receiptId/items', () => {
    it('should return receipt items', async () => {
      const mockReceipt = createMockReceipt();
      const mockItems = [
        createMockReceiptItem(),
        createMockReceiptItem({ receipt_item_id: 2, parsed_name: 'Bread' }),
      ];

      vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
      vi.mocked(receiptService.getReceiptItems).mockResolvedValueOnce(mockItems);

      const response = await request(app).get('/receipts/1/items');

      expect(response.status).toBe(200);
      expect(response.body.success).toBe(true);
      expect(response.body.data.items).toHaveLength(2);
      expect(response.body.data.total).toBe(2);
    });

    it('should return 404 if receipt not found', async () => {
      vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
        new NotFoundError('Receipt not found'),
      );

      const response = await request(app).get('/receipts/999/items');

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

  describe('PUT /receipts/:receiptId/items/:itemId', () => {
    it('should update receipt item status', async () => {
      const mockReceipt = createMockReceipt();
      const updatedItem = createMockReceiptItem({ status: 'matched', match_confidence: 0.95 });

      vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
      vi.mocked(receiptService.updateReceiptItem).mockResolvedValueOnce(updatedItem);

      const response = await request(app)
        .put('/receipts/1/items/1')
        .send({ status: 'matched', match_confidence: 0.95 });

      expect(response.status).toBe(200);
      expect(response.body.success).toBe(true);
      expect(response.body.data.status).toBe('matched');
      expect(receiptService.updateReceiptItem).toHaveBeenCalledWith(
        1,
        expect.objectContaining({ status: 'matched', match_confidence: 0.95 }),
        expect.anything(),
      );
    });

    it('should update item with master_item_id', async () => {
      const mockReceipt = createMockReceipt();
      const updatedItem = createMockReceiptItem({ master_item_id: 42 });

      vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
      vi.mocked(receiptService.updateReceiptItem).mockResolvedValueOnce(updatedItem);

      const response = await request(app).put('/receipts/1/items/1').send({ master_item_id: 42 });

      expect(response.status).toBe(200);
      expect(response.body.data.master_item_id).toBe(42);
    });

    it('should reject empty update body', async () => {
      const response = await request(app).put('/receipts/1/items/1').send({});

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

    it('should reject invalid status value', async () => {
      const response = await request(app)
        .put('/receipts/1/items/1')
        .send({ status: 'invalid_status' });

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

    it('should reject invalid match_confidence', async () => {
      const response = await request(app)
        .put('/receipts/1/items/1')
        .send({ match_confidence: 1.5 });

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

  describe('GET /receipts/:receiptId/items/unadded', () => {
    it('should return unadded items', async () => {
      const mockReceipt = createMockReceipt();
      const mockItems = [
        createMockReceiptItem({ added_to_inventory: false }),
        createMockReceiptItem({ receipt_item_id: 2, added_to_inventory: false }),
      ];

      vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
      vi.mocked(receiptService.getUnaddedItems).mockResolvedValueOnce(mockItems);

      const response = await request(app).get('/receipts/1/items/unadded');

      expect(response.status).toBe(200);
      expect(response.body.success).toBe(true);
      expect(response.body.data.items).toHaveLength(2);
      expect(response.body.data.total).toBe(2);
    });

    it('should return empty array when all items added', async () => {
      const mockReceipt = createMockReceipt();
      vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
      vi.mocked(receiptService.getUnaddedItems).mockResolvedValueOnce([]);

      const response = await request(app).get('/receipts/1/items/unadded');

      expect(response.status).toBe(200);
      expect(response.body.data.items).toHaveLength(0);
      expect(response.body.data.total).toBe(0);
    });
  });

  describe('POST /receipts/:receiptId/confirm', () => {
    it('should confirm items for inventory', async () => {
      const addedItems = [
        { inventory_id: 1, item_name: 'Milk 2%', quantity: 1 },
        { inventory_id: 2, item_name: 'Bread', quantity: 2 },
      ];

      vi.mocked(expiryService.addItemsFromReceipt).mockResolvedValueOnce(addedItems as any);

      const response = await request(app)
        .post('/receipts/1/confirm')
        .send({
          items: [
            { receipt_item_id: 1, include: true, location: 'fridge' },
            { receipt_item_id: 2, include: true, location: 'pantry', expiry_date: '2024-01-20' },
            { receipt_item_id: 3, include: false },
          ],
        });

      expect(response.status).toBe(200);
      expect(response.body.success).toBe(true);
      expect(response.body.data.added_items).toHaveLength(2);
      expect(response.body.data.count).toBe(2);
      expect(expiryService.addItemsFromReceipt).toHaveBeenCalledWith(
        mockUser!.user.user_id,
        1,
        expect.arrayContaining([
          expect.objectContaining({ receipt_item_id: 1, include: true }),
          expect.objectContaining({ receipt_item_id: 2, include: true }),
          expect.objectContaining({ receipt_item_id: 3, include: false }),
        ]),
        expect.anything(),
      );
    });

    it('should accept custom item_name and quantity', async () => {
      vi.mocked(expiryService.addItemsFromReceipt).mockResolvedValueOnce([
        { inventory_id: 1, item_name: 'Custom Name', quantity: 5 },
      ] as any);

      const response = await request(app)
        .post('/receipts/1/confirm')
        .send({
          items: [
            {
              receipt_item_id: 1,
              include: true,
              item_name: 'Custom Name',
              quantity: 5,
              location: 'pantry',
            },
          ],
        });

      expect(response.status).toBe(200);
      expect(expiryService.addItemsFromReceipt).toHaveBeenCalledWith(
        mockUser!.user.user_id,
        1,
        expect.arrayContaining([
          expect.objectContaining({
            item_name: 'Custom Name',
            quantity: 5,
          }),
        ]),
        expect.anything(),
      );
    });

    it('should accept empty items array', async () => {
      // Empty array is technically valid, service decides what to do
      vi.mocked(expiryService.addItemsFromReceipt).mockResolvedValueOnce([]);

      const response = await request(app).post('/receipts/1/confirm').send({ items: [] });

      expect(response.status).toBe(200);
      expect(response.body.data.count).toBe(0);
    });

    it('should reject missing items field', async () => {
      const response = await request(app).post('/receipts/1/confirm').send({});

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

    it('should reject invalid location', async () => {
      const response = await request(app)
        .post('/receipts/1/confirm')
        .send({
          items: [{ receipt_item_id: 1, include: true, location: 'invalid_location' }],
        });

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

    it('should reject invalid expiry_date format', async () => {
      const response = await request(app)
        .post('/receipts/1/confirm')
        .send({
          items: [{ receipt_item_id: 1, include: true, expiry_date: 'not-a-date' }],
        });

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

    it('should handle service error', async () => {
      vi.mocked(expiryService.addItemsFromReceipt).mockRejectedValueOnce(
        new Error('Failed to add items'),
      );

      const response = await request(app)
        .post('/receipts/1/confirm')
        .send({
          items: [{ receipt_item_id: 1, include: true }],
        });

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

  describe('GET /receipts/:receiptId/logs', () => {
    it('should return processing logs', async () => {
      const mockReceipt = createMockReceipt();
      const mockLogs = [
        createMockProcessingLog({
          processing_step: 'ocr_extraction' as const,
          status: 'completed' as const,
        }),
        createMockProcessingLog({
          log_id: 2,
          processing_step: 'item_extraction' as const,
          status: 'completed' as const,
        }),
        createMockProcessingLog({
          log_id: 3,
          processing_step: 'item_matching' as const,
          status: 'started' as const,
        }),
      ];

      vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
      vi.mocked(receiptService.getProcessingLogs).mockResolvedValueOnce(mockLogs);

      const response = await request(app).get('/receipts/1/logs');

      expect(response.status).toBe(200);
      expect(response.body.success).toBe(true);
      expect(response.body.data.logs).toHaveLength(3);
      expect(response.body.data.total).toBe(3);
    });

    it('should return empty logs for new receipt', async () => {
      const mockReceipt = createMockReceipt();
      vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
      vi.mocked(receiptService.getProcessingLogs).mockResolvedValueOnce([]);

      const response = await request(app).get('/receipts/1/logs');

      expect(response.status).toBe(200);
      expect(response.body.data.logs).toHaveLength(0);
      expect(response.body.data.total).toBe(0);
    });

    it('should return 404 for non-existent receipt', async () => {
      vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
        new NotFoundError('Receipt not found'),
      );

      const response = await request(app).get('/receipts/999/logs');

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

  describe('Authentication', () => {
    it('should reject unauthenticated requests', async () => {
      mockUser = null;
      app = createTestApp({
        router: receiptRouter,
        basePath: '/receipts',
        authenticatedUser: undefined,
      });

      const response = await request(app).get('/receipts');

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