import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProductsRepository } from '../../../src/modules/products/products.repository.js';

const { mockSave, MockProductModel } = vi.hoisted(() => {
  const mockSave = vi.fn();
  function MockProductModel(this: { save: typeof mockSave }, data: unknown) {
    Object.assign(this, data);
    this.save = mockSave;
  }
  Object.assign(MockProductModel, {
    findOne: vi.fn(),
    find: vi.fn(),
    findOneAndUpdate: vi.fn(),
    insertMany: vi.fn(),
  });
  return { mockSave, MockProductModel };
});

vi.mock('../../../src/schemas/product.schema.js', () => ({
  ProductModel: MockProductModel,
}));

const { ProductModel } = await import('../../../src/schemas/product.schema.js');

function makeChain(result: unknown = null) {
  return {
    sort: vi.fn().mockReturnThis(),
    limit: vi.fn().mockReturnThis(),
    lean: vi.fn().mockReturnThis(),
    exec: vi.fn().mockResolvedValue(result),
  };
}

describe(ProductsRepository.name, () => {
  let repo: ProductsRepository;

  beforeEach(() => {
    vi.clearAllMocks();
    repo = new ProductsRepository();
  });

  describe('findByHousehold', () => {
    it('applies householdId and deletedAt filters', async () => {
      const chain = makeChain([]);
      vi.mocked(ProductModel.find).mockReturnValue(chain as never);

      await repo.findByHousehold('hh1', { limit: 20 });

      expect(ProductModel.find).toHaveBeenCalledWith(
        expect.objectContaining({ householdId: 'hh1', deletedAt: { $exists: false } }),
      );
      expect(chain.sort).toHaveBeenCalledWith({ _id: 1 });
      expect(chain.limit).toHaveBeenCalledWith(21);
    });

    it('applies category filter', async () => {
      vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);

      await repo.findByHousehold('hh1', { limit: 20, category: 'meat' as never });

      expect(ProductModel.find).toHaveBeenCalledWith(expect.objectContaining({ category: 'meat' }));
    });

    it('applies barcode filter', async () => {
      vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);

      await repo.findByHousehold('hh1', { limit: 20, barcode: '1234567890' });

      expect(ProductModel.find).toHaveBeenCalledWith(
        expect.objectContaining({ barcode: '1234567890' }),
      );
    });

    it('applies text search via q', async () => {
      vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);

      await repo.findByHousehold('hh1', { limit: 20, q: 'chicken' });

      expect(ProductModel.find).toHaveBeenCalledWith(
        expect.objectContaining({ name: { $regex: 'chicken', $options: 'i' } }),
      );
    });

    it('applies tags filter', async () => {
      vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);

      await repo.findByHousehold('hh1', { limit: 20, tags: 'organic,fresh' });

      expect(ProductModel.find).toHaveBeenCalledWith(
        expect.objectContaining({ tags: { $all: ['organic', 'fresh'] } }),
      );
    });

    it('ignores empty tags string', async () => {
      vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);

      await repo.findByHousehold('hh1', { limit: 20, tags: '' });

      const call = vi.mocked(ProductModel.find).mock.calls[0][0] as Record<string, unknown>;
      expect(call).not.toHaveProperty('tags');
    });

    it('applies cursor filter', async () => {
      vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);

      const cursor = Buffer.from('p1').toString('base64');
      await repo.findByHousehold('hh1', { limit: 20, cursor });

      expect(ProductModel.find).toHaveBeenCalledWith(
        expect.objectContaining({ _id: { $gt: 'p1' } }),
      );
    });

    it('returns hasMore=true when extra item exists', async () => {
      const items = Array.from({ length: 21 }, (_, i) => ({
        _id: { toString: () => `p${i}` },
        name: `Item ${i}`,
      }));
      vi.mocked(ProductModel.find).mockReturnValue(makeChain(items) as never);

      const result = await repo.findByHousehold('hh1', { limit: 20 });

      expect(result.pagination.hasMore).toBe(true);
      expect(result.data).toHaveLength(20);
      expect(result.pagination.cursor).not.toBeNull();
    });

    it('returns hasMore=false and null cursor when empty', async () => {
      vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);

      const result = await repo.findByHousehold('hh1', { limit: 20 });

      expect(result.pagination.hasMore).toBe(false);
      expect(result.pagination.cursor).toBeNull();
    });
  });

  describe('findById', () => {
    it('queries by id and householdId without deletedAt filter', async () => {
      const mockProduct = { _id: 'p1', name: 'Apple', householdId: 'hh1' };
      const chain = {
        lean: vi.fn().mockReturnThis(),
        exec: vi.fn().mockResolvedValue(mockProduct),
      };
      vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);

      const result = await repo.findById('p1', 'hh1');
      expect(ProductModel.findOne).toHaveBeenCalledWith({ _id: 'p1', householdId: 'hh1' });
      expect(result).toEqual(mockProduct);
    });
  });

  describe('findByIds', () => {
    it('queries by multiple ids and householdId', async () => {
      const products = [
        { _id: 'p1', name: 'Apple' },
        { _id: 'p2', name: 'Banana' },
      ];
      vi.mocked(ProductModel.find).mockReturnValue(makeChain(products) as never);

      const result = await repo.findByIds('hh1', ['p1', 'p2']);
      expect(ProductModel.find).toHaveBeenCalledWith({
        _id: { $in: ['p1', 'p2'] },
        householdId: 'hh1',
      });
      expect(result).toEqual(products);
    });
  });

  describe('findByBarcode', () => {
    it('queries by householdId, barcode, and excludes deleted', async () => {
      const mockProduct = { _id: 'p1', barcode: '1234567890' };
      const chain = {
        lean: vi.fn().mockReturnThis(),
        exec: vi.fn().mockResolvedValue(mockProduct),
      };
      vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);

      const result = await repo.findByBarcode('hh1', '1234567890');
      expect(ProductModel.findOne).toHaveBeenCalledWith({
        householdId: 'hh1',
        barcode: '1234567890',
        deletedAt: { $exists: false },
      });
      expect(result).toEqual(mockProduct);
    });
  });

  describe('findDuplicate', () => {
    it('queries by householdId and name', async () => {
      const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
      vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);

      await repo.findDuplicate('hh1', 'Apple');
      expect(ProductModel.findOne).toHaveBeenCalledWith(
        expect.objectContaining({
          householdId: 'hh1',
          name: 'Apple',
          deletedAt: { $exists: false },
        }),
      );
    });

    it('includes brand in filter when provided', async () => {
      const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
      vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);

      await repo.findDuplicate('hh1', 'Apple', 'Dole');
      expect(ProductModel.findOne).toHaveBeenCalledWith(expect.objectContaining({ brand: 'Dole' }));
    });

    it('excludes the given id when excludeId provided', async () => {
      const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
      vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);

      await repo.findDuplicate('hh1', 'Apple', undefined, 'p1');
      expect(ProductModel.findOne).toHaveBeenCalledWith(
        expect.objectContaining({ _id: { $ne: 'p1' } }),
      );
    });

    it('does not include _id filter when no excludeId', async () => {
      const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
      vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);

      await repo.findDuplicate('hh1', 'Apple');
      const call = vi.mocked(ProductModel.findOne).mock.calls[0][0] as Record<string, unknown>;
      expect(call).not.toHaveProperty('_id');
    });

    it('does not include brand filter when brand is undefined', async () => {
      const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
      vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);

      await repo.findDuplicate('hh1', 'Apple', undefined);
      const call = vi.mocked(ProductModel.findOne).mock.calls[0][0] as Record<string, unknown>;
      expect(call).not.toHaveProperty('brand');
    });
  });

  describe('update', () => {
    it('calls findOneAndUpdate with correct filter and data', async () => {
      vi.mocked(ProductModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'p1' }) as never);

      await repo.update('p1', 'hh1', { name: 'Updated' });

      expect(ProductModel.findOneAndUpdate).toHaveBeenCalledWith(
        { _id: 'p1', householdId: 'hh1', deletedAt: { $exists: false } },
        { $set: { name: 'Updated' } },
        { new: true, lean: true },
      );
    });
  });

  describe('softDelete', () => {
    it('sets deletedAt on the document', async () => {
      vi.mocked(ProductModel.findOneAndUpdate).mockReturnValue(
        makeChain({ _id: 'p1', deletedAt: new Date() }) as never,
      );

      await repo.softDelete('p1', 'hh1');

      expect(ProductModel.findOneAndUpdate).toHaveBeenCalledWith(
        { _id: 'p1', householdId: 'hh1', deletedAt: { $exists: false } },
        { $set: { deletedAt: expect.any(Date) } },
        { new: true, lean: true },
      );
    });
  });

  describe('create', () => {
    it('saves and returns the new document as plain object', async () => {
      const plainDoc = { _id: 'new-id', name: 'Apple' };
      mockSave.mockResolvedValue({ toObject: () => plainDoc });

      const result = await repo.create({ name: 'Apple' });

      expect(mockSave).toHaveBeenCalled();
      expect(result).toEqual(plainDoc);
    });
  });

  describe('bulkCreate', () => {
    it('calls insertMany with householdId merged into each item', async () => {
      vi.mocked(ProductModel.insertMany).mockResolvedValue([] as never);

      await repo.bulkCreate('hh1', [{ name: 'Apple' }, { name: 'Banana' }]);

      expect(ProductModel.insertMany).toHaveBeenCalledWith(
        [
          { name: 'Apple', householdId: 'hh1' },
          { name: 'Banana', householdId: 'hh1' },
        ],
        { ordered: false },
      );
    });
  });
});
