import { RemainderPlacementService } from './remainder-placement.service';

/**
 * The remainder placer reconstructs the outstanding split remainder as a new
 * booking. The delicate parts under test: bulk sizes from the outstanding tons;
 * container recovers real numbers from the SOFT-DELETED units (never fabricates)
 * and throws on a shortfall; and nothing is placed when there's no outstanding
 * or no fitting train.
 */
describe('RemainderPlacementService', () => {
  const DAY = '2026-07-20';

  function make(opts: {
    freightType: 'CONTAINER' | 'BULK';
    contractKind?: 'ONE_TIME' | 'GENERAL';
    outstanding: unknown;
    createThrows?: Error;
    deferredUnits?: Array<{
      containerNumber: string;
      vgmTons: number;
      isHazardous?: boolean;
      isReefer?: boolean;
    }>;
    fittingTrains?: Array<{ scheduleId: string }>;
  }) {
    const contract = {
      id: 'c-1',
      freightType: opts.freightType,
      contractKind: opts.contractKind ?? 'ONE_TIME',
    };
    const contractsRepository = {
      findByIdWithRelations: jest.fn().mockResolvedValue(contract),
    };
    const createUnderContract = opts.createThrows
      ? jest.fn().mockRejectedValue(opts.createThrows)
      : jest
          .fn()
          .mockResolvedValue({ booking: { id: 'rem-1', reference: 'BKG-R' }, warnings: [] });
    const contractBookingService = {
      splitOutstanding: jest.fn().mockResolvedValue(opts.outstanding),
      createUnderContract,
    };
    const bookingBatchService = {
      fittingTrainsForDay: jest
        .fn()
        .mockResolvedValue(opts.fittingTrains ?? [{ scheduleId: 's-2' }]),
    };
    // getRepository is only hit on the container path (recoverDeferredUnits).
    const lineRepo = {
      find: jest.fn().mockResolvedValue([{ id: 'line-1' }]),
    };
    const unitRepo = {
      find: jest.fn().mockResolvedValue(opts.deferredUnits ?? []),
    };
    const dataSource = {
      getRepository: jest.fn((entity: { name?: string }) => {
        const n = entity?.name ?? '';
        if (n.includes('Unit')) return unitRepo;
        return lineRepo;
      }),
    };
    const notifier = { remainderPlaced: jest.fn() };
    const service = new RemainderPlacementService(
      dataSource as never,
      contractsRepository as never,
      contractBookingService as never,
      bookingBatchService as never,
      notifier as never,
    );
    return {
      service,
      createUnderContract,
      contractBookingService,
      bookingBatchService,
      notifier,
    };
  }

  const splitBooking = {
    id: 'bk-1',
    reference: 'BKG-1',
    contractId: 'c-1',
    scheduledDate: new Date('2026-07-20T06:00:00Z'),
    createdByUserId: 'u-1',
  } as never;

  it('sizes a BULK remainder from the outstanding tons', async () => {
    const { service, createUnderContract } = make({
      freightType: 'BULK',
      outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
    });
    const id = await service.placeRemainder(splitBooking);
    expect(id).toBe('rem-1');
    const dto = createUnderContract.mock.calls[0][1];
    expect(dto.bulkLines).toEqual([{ cargoWeightTons: 40 }]);
    expect(dto.scheduledDate).toBe(DAY);
  });

  it('rebuilds a CONTAINER remainder from the soft-deleted units', async () => {
    const deferredUnits = [
      { containerNumber: 'ABCD1234567', vgmTons: 12, isReefer: true },
      { containerNumber: 'ABCD7654321', vgmTons: 10, isHazardous: true },
    ];
    const { service, createUnderContract } = make({
      freightType: 'CONTAINER',
      outstanding: {
        bySize: new Map([['40ft', { total: 5, outstanding: 2 }]]),
        bulk: null,
      },
      deferredUnits,
    });
    const id = await service.placeRemainder(splitBooking);
    expect(id).toBe('rem-1');
    const dto = createUnderContract.mock.calls[0][1];
    expect(dto.containers).toHaveLength(1);
    const line = dto.containers[0];
    expect(line.containerSize).toBe('40ft');
    expect(line.quantity).toBe(2);
    expect(line.units.map((u: { containerNumber: string }) => u.containerNumber)).toEqual([
      'ABCD1234567',
      'ABCD7654321',
    ]);
    expect(line.reeferQuantity).toBe(1);
    expect(line.hazardousQuantity).toBe(1);
  });

  it('throws (→ no placement) when fewer units are recoverable than outstanding — never fabricates', async () => {
    const { service, createUnderContract } = make({
      freightType: 'CONTAINER',
      outstanding: {
        bySize: new Map([['40ft', { total: 5, outstanding: 3 }]]),
        bulk: null,
      },
      deferredUnits: [{ containerNumber: 'ABCD1234567', vgmTons: 12 }], // only 1, need 3
    });
    const id = await service.placeRemainder(splitBooking);
    expect(id).toBeNull();
    expect(createUnderContract).not.toHaveBeenCalled();
  });

  it('is a no-op when there is no outstanding remainder', async () => {
    const { service, createUnderContract } = make({
      freightType: 'BULK',
      outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 0 } },
    });
    const id = await service.placeRemainder(splitBooking);
    expect(id).toBeNull();
    expect(createUnderContract).not.toHaveBeenCalled();
  });

  it('tells the customer the leftover wagons were booked on another train', async () => {
    const { service, notifier } = make({
      freightType: 'BULK',
      outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
    });
    await service.placeRemainder(splitBooking);
    expect(notifier.remainderPlaced).toHaveBeenCalledWith(
      expect.objectContaining({ id: 'rem-1' }),
      'BKG-1',
    );
  });

  it('never double-books the leftover when two payments land together', async () => {
    const { service, createUnderContract } = make({
      freightType: 'BULK',
      outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
    });
    // Both callers enter before either create commits.
    await Promise.all([
      service.placeRemainder(splitBooking),
      service.placeRemainder(splitBooking),
    ]);
    expect(createUnderContract).toHaveBeenCalledTimes(1);
  });

  // splitOutstanding subtracts a CONTRACT-WIDE booked total from ONE booking's
  // snapshot — coherent only for ONE_TIME. On GENERAL that mixes scopes and
  // either drops a real remainder or double-draws the cap, so we must not place.
  it('never auto-places on a GENERAL contract (cap ledger mismatch)', async () => {
    const { service, createUnderContract, contractBookingService } = make({
      freightType: 'BULK',
      contractKind: 'GENERAL',
      outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
    });
    const id = await service.placeRemainder(splitBooking);
    expect(id).toBeNull();
    expect(createUnderContract).not.toHaveBeenCalled();
    expect(contractBookingService.splitOutstanding).not.toHaveBeenCalled();
  });

  // The paid booking has already boarded — a create-gate rejection (e.g. the
  // export whole-train gate) must leave the remainder rebookable, not escape.
  it('swallows a create rejection and leaves the remainder for manual rebook', async () => {
    const { service } = make({
      freightType: 'BULK',
      outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
      createThrows: new Error('Not enough train space for this day.'),
    });
    await expect(service.placeRemainder(splitBooking)).resolves.toBeNull();
  });

  it('is a no-op when the contract has no split chain', async () => {
    const { service, createUnderContract } = make({
      freightType: 'BULK',
      outstanding: null,
    });
    const id = await service.placeRemainder(splitBooking);
    expect(id).toBeNull();
    expect(createUnderContract).not.toHaveBeenCalled();
  });
});
