import { WagonTransferRequestStatus } from '@edr/types';
import { ConflictException, BadRequestException } from '@nestjs/common';

import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
import type { WagonTransferRequest } from './entities/wagon-transfer-request.entity';

/**
 * Instalment fulfilment: a request for 50 wagons is met with whatever the source
 * yard can spare, whenever it can spare it. It stays open until the full count
 * lands or OCC closes it short — which is what tells the requester to go ask
 * another yard.
 */
describe('WagonTransferRequestsService — partial fulfilment', () => {
  const request = (over: Partial<WagonTransferRequest> = {}): WagonTransferRequest =>
    ({
      id: 'req-1',
      fromYardId: 'yard-a',
      toYardId: 'yard-b',
      wagonTypeId: 'type-1',
      quantity: 50,
      fulfilledQuantity: 0,
      status: WagonTransferRequestStatus.Pending,
      requestedByUserId: 'user-1',
      ...over,
    }) as WagonTransferRequest;

  let requestRepo: {
    findOne: jest.Mock;
    find: jest.Mock;
    save: jest.Mock;
    create: jest.Mock;
    createQueryBuilder: jest.Mock;
  };
  let wagonRepo: { find: jest.Mock; count: jest.Mock };
  let wagonsService: { bulkTransfer: jest.Mock };
  let inbox: { notify: jest.Mock };
  let service: WagonTransferRequestsService;
  let stored: WagonTransferRequest;

  const flush = () => new Promise((resolve) => setImmediate(resolve));

  const build = (row: WagonTransferRequest) => {
    stored = row;
    requestRepo.findOne.mockImplementation(async () => stored);
    requestRepo.save.mockImplementation(async (r: WagonTransferRequest) => {
      stored = r;
      return r;
    });
  };

  beforeEach(() => {
    requestRepo = {
      findOne: jest.fn(),
      find: jest.fn().mockResolvedValue([]),
      save: jest.fn(),
      create: jest.fn((r) => r),
      createQueryBuilder: jest.fn(),
    };
    wagonRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn() };
    wagonsService = { bulkTransfer: jest.fn().mockResolvedValue(undefined) };
    inbox = { notify: jest.fn().mockResolvedValue(undefined) };
    service = new WagonTransferRequestsService(
      requestRepo as never,
      wagonRepo as never,
      { find: jest.fn(), findAndCount: jest.fn() } as never,
      wagonsService as never,
      inbox as never,
    );
    build(request());
  });

  const availableWagons = (n: number) =>
    Array.from({ length: n }, (_, i) => ({
      id: `w-${i}`,
      wagonNumber: `100${i}`,
      currentYardId: 'yard-a',
      wagonTypeId: 'type-1',
      status: 'AVAILABLE',
    }));

  describe('fulfillRequest', () => {
    it('books an instalment and keeps the request open', async () => {
      wagonRepo.find.mockResolvedValue(availableWagons(20));

      await service.fulfillRequest('req-1', {
        wagonIds: availableWagons(20).map((w) => w.id),
      });

      expect(stored.fulfilledQuantity).toBe(20);
      expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
      expect(wagonsService.bulkTransfer).toHaveBeenCalledTimes(1);
    });

    it('completes the request when the last instalment lands', async () => {
      build(request({ fulfilledQuantity: 30, status: WagonTransferRequestStatus.PartiallyFulfilled }));
      wagonRepo.find.mockResolvedValue(availableWagons(20));

      await service.fulfillRequest('req-1', {
        wagonIds: availableWagons(20).map((w) => w.id),
      });

      expect(stored.fulfilledQuantity).toBe(50);
      expect(stored.status).toBe(WagonTransferRequestStatus.Fulfilled);
    });

    it('refuses to move more than is still owed', async () => {
      build(request({ fulfilledQuantity: 45, status: WagonTransferRequestStatus.PartiallyFulfilled }));
      wagonRepo.find.mockResolvedValue(availableWagons(10));

      await expect(
        service.fulfillRequest('req-1', {
          wagonIds: availableWagons(10).map((w) => w.id),
        }),
      ).rejects.toBeInstanceOf(BadRequestException);
      expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
    });

    it('refuses to touch a request that is already closed', async () => {
      build(request({ status: WagonTransferRequestStatus.ClosedShort, fulfilledQuantity: 20 }));

      await expect(
        service.fulfillRequest('req-1', { wagonIds: ['w-0'] }),
      ).rejects.toBeInstanceOf(ConflictException);
    });

    it('tells the requester what landed and what is still owed', async () => {
      wagonRepo.find.mockResolvedValue(availableWagons(20));

      await service.fulfillRequest('req-1', {
        wagonIds: availableWagons(20).map((w) => w.id),
      });
      await flush();

      const sent = inbox.notify.mock.calls[0][0];
      expect(sent.recipients).toEqual({ userIds: ['user-1'] });
      expect(sent.body).toContain('20 wagon(s) have arrived');
      expect(sent.body).toContain('30 of 50 still to come');
    });
  });

  describe('bulkFulfill', () => {
    it('sends what the yard has instead of skipping a short request', async () => {
      wagonRepo.find.mockResolvedValue(availableWagons(20));

      const result = await service.bulkFulfill(['req-1']);

      expect(stored.fulfilledQuantity).toBe(20);
      expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
      expect(result.skipped).toHaveLength(0);
    });

    it('skips only when the yard has nothing to give', async () => {
      wagonRepo.find.mockResolvedValue([]);

      const result = await service.bulkFulfill(['req-1']);

      expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
      expect(result.skipped[0].reason).toContain('No available wagons');
    });
  });

  describe('closeShort', () => {
    it('ends the request and tells the requester to ask another yard', async () => {
      build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));

      await service.closeShort('req-1', { note: 'Yard is empty until Friday' });
      await flush();

      expect(stored.status).toBe(WagonTransferRequestStatus.ClosedShort);
      expect(stored.closedShortAt).toBeInstanceOf(Date);
      const sent = inbox.notify.mock.calls[0][0];
      expect(sent.body).toContain('Only 20 of the 50');
      expect(sent.body).toContain('Yard is empty until Friday');
      expect(sent.body).toContain('Request the remaining 30');
    });

    it('refuses when the request is already fully supplied', async () => {
      build(request({ fulfilledQuantity: 50, status: WagonTransferRequestStatus.PartiallyFulfilled }));

      await expect(service.closeShort('req-1', {})).rejects.toBeInstanceOf(
        ConflictException,
      );
    });
  });

  describe('cancelRequest', () => {
    it('withdraws a request that never moved a wagon', async () => {
      await service.cancelRequest('req-1');
      expect(stored.status).toBe(WagonTransferRequestStatus.Cancelled);
    });

    it('refuses once wagons have moved — close it short instead', async () => {
      build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));

      await expect(service.cancelRequest('req-1')).rejects.toThrow(
        /close it short/i,
      );
    });
  });

  describe('createRequest', () => {
    it('accepts a count larger than what the yard holds today', async () => {
      wagonRepo.count.mockResolvedValue(20);

      await service.createRequest(
        {
          fromYardId: 'yard-a',
          toYardId: 'yard-b',
          wagonTypeId: 'type-1',
          quantity: 50,
          reason: 'Grain campaign',
        },
        'user-1',
      );

      expect(requestRepo.save).toHaveBeenCalled();
      expect(stored.quantity).toBe(50);
    });

    it('still refuses a same-yard move', async () => {
      await expect(
        service.createRequest(
          {
            fromYardId: 'yard-a',
            toYardId: 'yard-a',
            wagonTypeId: 'type-1',
            quantity: 5,
            reason: 'x',
          },
          'user-1',
        ),
      ).rejects.toBeInstanceOf(BadRequestException);
    });
  });
});
