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

import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulingService } from './train-scheduling.service';

const nw5 = {
  id: 'wagon-type-1',
  code: 'NW5',
  name: 'Flat Wagon',
  capacityTons: 70,
  lengthMeters: 14,
  supportedLoadTypes: ['CONTAINER'],
  isActive: true,
  supportsContainer: true,
};

const locomotive = {
  id: 'loc-1',
  code: 'LOC-001',
  maxPullWeightTons: 3500,
  maxTrainLengthMeters: 760,
  status: 'AVAILABLE',
  currentYardId: 'yard-origin',
};

const cw3 = {
  id: 'wagon-type-bulk',
  code: 'CW3',
  name: 'Covered Wagon',
  capacityTons: 60,
  lengthMeters: 14,
  supportedLoadTypes: ['BULK'],
  isActive: true,
  supportsContainer: false,
};

const makeBooking = (
  id: string,
  reference: string,
  weight: number,
  quantity: number,
  containerCode: string,
  wagonsRequired: number,
  scheduledDate = '2026-06-20T08:00:00.000Z',
  originYardId = 'yard-origin',
  destinationYardId = 'yard-destination',
  extra: Record<string, unknown> = {},
) => ({
  id,
  reference,
  freightType: 'CONTAINER',
  cargoTotalWeightVgm: weight,
  scheduledDate: new Date(scheduledDate),
  originYardId,
  destinationYardId,
  status: 'PAID',
  schedulingStatus: 'HOLDING',
  holdExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
  company: { companyName: 'Demo Customer' },
  originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
  destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
  bookingContainers: [
    {
      id: `${id}-line`,
      containerTypeId: 'ct-1',
      quantity,
      wagonsRequired,
      vgmPerUnitTons: weight / quantity,
      isOverweight: false,
      containerType: { id: 'ct-1', code: containerCode, label: containerCode, wagonTypes: [nw5] },
    },
  ],
  ...extra,
});

describe('TrainSchedulingService', () => {
  let service: TrainSchedulingService;
  let dataSource: {
    getRepository: jest.Mock;
    transaction: jest.Mock;
    query: jest.Mock;
    manager: { getRepository: jest.Mock };
  };
  let bookingsRepository: Record<string, jest.Mock>;
  let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
  let wagonTypesRepository: { findAll: jest.Mock };
  let trainSchedulesRepository: Record<string, jest.Mock>;
  let trainScheduleBookingsRepository: Record<string, jest.Mock>;
  let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
  let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
  let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;

  beforeEach(() => {
    // findGroupSiblings runs a query builder off dataSource.manager; default it
    // to "no sibling schedules" so isolated unit tests don't need to wire it.
    const emptySiblingQb = {
      where: jest.fn().mockReturnThis(),
      andWhere: jest.fn().mockReturnThis(),
      getMany: jest.fn().mockResolvedValue([]),
    };
    dataSource = {
      getRepository: jest.fn(),
      transaction: jest.fn(),
      // Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
      query: jest.fn().mockResolvedValue([]),
      manager: {
        getRepository: jest.fn(() => ({
          createQueryBuilder: jest.fn(() => emptySiblingQb),
        })),
      },
    };
    bookingsRepository = {
      findEligibleForScheduling: jest.fn(),
      findByIdsForScheduling: jest.fn(),
      findAll: jest.fn(),
      updateSchedulingFields: jest.fn(),
    };
    locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
    wagonTypesRepository = { findAll: jest.fn() };
    trainSchedulesRepository = {
      findById: jest.fn(),
      findByIdWithFullGraph: jest.fn(),
      findAll: jest.fn(),
      updateStatus: jest.fn(),
      maxReferenceSequence: jest.fn().mockResolvedValue(0),
    };
    trainScheduleBookingsRepository = {
      findByBookingIds: jest.fn(),
      findByScheduleId: jest.fn().mockResolvedValue([]),
      createMany: jest.fn(),
      deleteByScheduleAndBooking: jest.fn(),
    };
    wagonBookingAllocationsRepository = {
      deleteByTrainSetId: jest.fn().mockResolvedValue([]),
      createMany: jest.fn(),
    };
    wagonAllocationContainerItemsRepository = {
      createMany: jest.fn(),
      deleteByAllocationIds: jest.fn(),
      findAll: jest.fn().mockResolvedValue([]),
    };
    wagonAllocationBulkLoadsRepository = {
      createMany: jest.fn(),
      deleteByAllocationIds: jest.fn(),
      findAll: jest.fn().mockResolvedValue([]),
    };

    const trainCheckpointEventsRepository = {
      findBySchedule: jest.fn().mockResolvedValue([]),
      findAll: jest.fn().mockResolvedValue([]),
      create: jest.fn(),
      update: jest.fn(),
    };

    service = new TrainSchedulingService(
      dataSource as never,
      bookingsRepository as never,
      locomotivesRepository as never,
      wagonTypesRepository as never,
      trainSchedulesRepository as never,
      trainScheduleBookingsRepository as never,
      wagonBookingAllocationsRepository as never,
      wagonAllocationContainerItemsRepository as never,
      wagonAllocationBulkLoadsRepository as never,
      trainCheckpointEventsRepository as never,
      {} as never, // trainCompositionRemovalLogRepository
      {
        autoUnloadArrivedBookings: jest.fn(),
        autoUnloadExportAtDjibouti: jest.fn(),
      } as never,
      {
        htmlToPdfBuffer: jest.fn(),
      } as never,
      { emitPhase: jest.fn() } as never, // bookingWindowGateway
      {
        autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
      } as never, // bookingJourneyService
      { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
    );

    const defaultFleetWagons = [
      ...Array.from({ length: 100 }, (_, index) => ({
        id: `wagon-nw5-${index}`,
        wagonTypeId: nw5.id,
        status: WagonStatus.Available,
        currentYardId: 'yard-origin',
        currentTrainScheduleId: null,
      })),
      ...Array.from({ length: 50 }, (_, index) => ({
        id: `wagon-cw3-${index}`,
        wagonTypeId: cw3.id,
        status: WagonStatus.Available,
        currentYardId: 'yard-origin',
        currentTrainScheduleId: null,
      })),
    ];

    dataSource.getRepository.mockImplementation((entity: unknown) => {
      if (entity === TrainSchedulingGlobalRules) {
        return { find: jest.fn().mockResolvedValue([]) };
      }
      if (entity === Wagon) {
        return { find: jest.fn().mockResolvedValue(defaultFleetWagons) };
      }
      if (entity === WagonType) {
        return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
      }
      return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
    });
  });

  it('returns fleet availability and defers bookings when fleet is insufficient', async () => {
    const bookings = [
      makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
      makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
    ];

    wagonTypesRepository.findAll.mockResolvedValue([nw5]);
    bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
    trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
    locomotivesRepository.findAll.mockResolvedValue([locomotive]);

    const availableWagons = Array.from({ length: 15 }, (_, index) => ({
      id: `wagon-${index}`,
      wagonTypeId: nw5.id,
      status: WagonStatus.Available,
      currentYardId: 'yard-origin',
      currentTrainScheduleId: null,
    }));

    dataSource.getRepository.mockImplementation((entity: unknown) => {
      if (entity === TrainSchedulingGlobalRules) {
        return { find: jest.fn().mockResolvedValue([]) };
      }
      if (entity === Wagon) {
        return { find: jest.fn().mockResolvedValue(availableWagons) };
      }
      if (entity === WagonType) {
        return { find: jest.fn().mockResolvedValue([nw5]) };
      }
      return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
    });

    const result = await service.previewContainerTrainSchedule({
      bookingIds: bookings.map((b) => b.id),
      scheduleDate: '2026-06-20T08:00:00.000Z',
      originStationId: 'yard-origin',
      destinationStationId: 'yard-destination',
    });

    // Availability rows now report what the BOUNDED plan actually uses per
    // type (never more than stock, so no shortfall on the rows themselves);
    // the shortage is carried by the deferred bookings' own shortage rows.
    expect(result.fleetAvailability?.length).toBeGreaterThan(0);
    expect(
      result.fleetAvailability?.every((row) => row.needed <= row.available),
    ).toBe(true);
    expect(result.deferredBookings?.length).toBeGreaterThan(0);
    expect(result.deferredBookings?.[0]?.reason).toContain('short');
    expect(result.summary.wagonsNeeded).toBeLessThan(30);
    expect(result.warnings.some((w) => w.includes('deferred'))).toBe(true);
  });

  it('computes slot-based preview for Group A', async () => {
    const bookings = [
      makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
      makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
      makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT', 15),
    ];

    wagonTypesRepository.findAll.mockResolvedValue([nw5]);
    bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
    trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
    locomotivesRepository.findAll.mockResolvedValue([locomotive]);

    const result = await service.previewContainerTrainSchedule({
      bookingIds: bookings.map((b) => b.id),
      scheduleDate: '2026-06-20T08:00:00.000Z',
      originStationId: 'yard-origin',
      destinationStationId: 'yard-destination',
    });

    expect(result.valid).toBe(true);
    expect(result.violations).toEqual([]);
    // TEU packing: 20 + 15 wagons of 40ft plus 10×20ft at two per wagon (5) —
    // the planner packs by container size, not the stored per-line fallback.
    expect(result.summary.wagonsNeeded).toBe(40);
    expect(result.wagonPlan).toHaveLength(40);
  });

  it('returns soft hold warnings without forceAssign', async () => {
    const bookings = [makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2)];

    wagonTypesRepository.findAll.mockResolvedValue([nw5]);
    bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
    trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
    locomotivesRepository.findAll.mockResolvedValue([locomotive]);

    const result = await service.previewContainerTrainSchedule({
      bookingIds: ['b7'],
      scheduleDate: '2026-06-20T08:00:00.000Z',
      originStationId: 'yard-origin',
      destinationStationId: 'yard-destination',
    });

    expect(result.warnings.length).toBeGreaterThan(0);
    expect(result.warnings[0]).toContain('soft hold window');
  });

  it('warns on the overweight booking but still allows scheduling', async () => {
    const bookings = [
      makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, {
        bookingContainers: [
          {
            id: 'b6-line',
            containerTypeId: 'ct-1',
            quantity: 80,
            wagonsRequired: 80,
            vgmPerUnitTons: 45,
            isOverweight: true,
            containerType: { id: 'ct-1', code: '40FT', label: '40FT', wagonTypes: [nw5] },
          },
        ],
      }),
    ];

    wagonTypesRepository.findAll.mockResolvedValue([nw5]);
    bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
    trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
    locomotivesRepository.findAll.mockResolvedValue([locomotive]);

    const result = await service.previewContainerTrainSchedule({
      bookingIds: ['b6'],
      scheduleDate: '2026-06-20T08:00:00.000Z',
      originStationId: 'yard-origin',
      destinationStationId: 'yard-destination',
    });

    expect(result.violations.some((v) => v.includes('overweight'))).toBe(false);
    expect(result.warnings.some((w) => w.includes('overweight'))).toBe(true);
  });

  it('allows preview when bookings are already on the target schedule', async () => {
    // A booking already pinned to the target schedule is exempt from the
    // corridor/day/status gates — mark it so on the entity, matching the link row.
    const bookings = [
      { ...makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), trainScheduleId: 'sched-target' },
    ];

    wagonTypesRepository.findAll.mockResolvedValue([nw5]);
    bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
    trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([
      { bookingId: 'b1', trainScheduleId: 'sched-target' },
    ]);
    trainSchedulesRepository.findById.mockResolvedValue({
      id: 'sched-target',
      direction: 'IMPORT',
    });
    locomotivesRepository.findAll.mockResolvedValue([locomotive]);

    const result = await service.previewContainerTrainSchedule({
      bookingIds: ['b1'],
      scheduleDate: '2026-06-20T08:00:00.000Z',
      originStationId: 'yard-origin',
      destinationStationId: 'yard-destination',
      targetScheduleId: 'sched-target',
    });

    expect(result.violations).not.toContain(
      'One or more selected bookings are already assigned to a train schedule',
    );
    expect(result.valid).toBe(true);
  });

  it('flags a booking scheduled for a different day than the train departure', async () => {
    // The old cross-booking "must share the same schedule date" rule is gone;
    // the live rule is that every booking must match the departure day. b2
    // departs a day later, so it's the one flagged.
    const bookings = [
      makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
      makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
    ];

    wagonTypesRepository.findAll.mockResolvedValue([nw5]);
    bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
    trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
    locomotivesRepository.findAll.mockResolvedValue([locomotive]);

    const result = await service.previewContainerTrainSchedule({
      bookingIds: bookings.map((b) => b.id),
      scheduleDate: '2026-06-20T08:00:00.000Z',
      originStationId: 'yard-origin',
      destinationStationId: 'yard-destination',
    });

    expect(result.violations).not.toContain(
      'Selected bookings must share the same schedule date',
    );
    expect(result.violations.some((v) => v.includes('different day'))).toBe(true);
    expect(result.valid).toBe(false);
  });

  it('rejects bookings that are not in schedulable status', async () => {
    const bookings = [
      { ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' },
    ];

    wagonTypesRepository.findAll.mockResolvedValue([nw5]);
    bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
    trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
    locomotivesRepository.findAll.mockResolvedValue([locomotive]);

    const result = await service.previewContainerTrainSchedule({
      bookingIds: ['b7'],
      scheduleDate: '2026-06-20T08:00:00.000Z',
      originStationId: 'yard-origin',
      destinationStationId: 'yard-destination',
    });

    expect(result.valid).toBe(false);
    expect(result.violations).toContain(
      'Only PAID bookings can be scheduled; received: APPROVED',
    );
  });

  it('creates a schedule transactionally when validation passes', async () => {
    const route = {
      id: 'route-1',
      name: 'Djibouti to Addis',
      originYardId: 'yard-origin',
      destinationYardId: 'yard-destination',
      isActive: true,
      status: 'AVAILABLE',
      direction: 'IMPORT',
    };

    const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
    const lockedLocomotiveRepo = {
      findOne: jest
        .fn()
        .mockResolvedValueOnce(locomotive)
        .mockResolvedValueOnce(locomotive2),
      update: jest.fn().mockResolvedValue(undefined),
    };
    const trainScheduleRepo = {
      create: jest.fn().mockImplementation((value) => value),
      save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
      // findGroupWindowAnchor looks for same-day sibling schedules; none here.
      createQueryBuilder: jest.fn(() => ({
        where: jest.fn().mockReturnThis(),
        andWhere: jest.fn().mockReturnThis(),
        getMany: jest.fn().mockResolvedValue([]),
      })),
    };
    const trainSetRepo = {
      create: jest.fn().mockImplementation((value) => value),
      save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
    };
    const trainSetLocomotiveRepo = {
      create: jest.fn().mockImplementation((value) => value),
      save: jest.fn().mockResolvedValue(undefined),
    };
    const manager = {
      getRepository: jest.fn((entity: { name?: string }) => {
        switch (entity?.name) {
          case 'Locomotive':
            return lockedLocomotiveRepo;
          case 'TrainSchedule':
            return trainScheduleRepo;
          case 'TrainSet':
            return trainSetRepo;
          case 'TrainSetLocomotive':
            return trainSetLocomotiveRepo;
          default:
            throw new Error(`Unexpected transaction repository ${entity?.name}`);
        }
      }),
    };

    dataSource.getRepository.mockImplementation((entity: unknown) => {
      if ((entity as { name?: string })?.name === 'Route') {
        return { findOne: jest.fn().mockResolvedValue(route) };
      }
      if (entity === TrainSchedulingGlobalRules) {
        return { find: jest.fn().mockResolvedValue([]) };
      }
      if (entity === WagonType) {
        return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
      }
      throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
    });
    trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
    dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
      callback(manager),
    );

    // Departure must clear the import lead window (≥ importWindowLeadDays ahead
    // of now), so use a comfortably-future date rather than a hardcoded one.
    const futureDeparture = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
    const result = await service.createContainerTrainSchedule({
      routeId: 'route-1',
      scheduleDate: futureDeparture,
      locomotiveIds: ['loc-1', 'loc-2'],
    });

    expect(trainSetRepo.save).toHaveBeenCalled();
    expect(trainScheduleRepo.save).toHaveBeenCalled();
    expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
    // Advance scheduling locks locomotives but does NOT flip them to ASSIGNED —
    // one locomotive may sit on several future schedules.
    expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled();
    expect(result.id).toBe('schedule-1');
  });

  it('previews mixed container and bulk bookings', async () => {
    const containerBooking = makeBooking('c1', 'BKG-CONT', 100, 2, '40FT', 2);
    const bulkBooking = {
      id: 'b1',
      reference: 'BKG-BULK',
      freightType: 'BULK',
      cargoTotalWeightVgm: 120,
      scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
      originYardId: 'yard-origin',
      destinationYardId: 'yard-destination',
      status: 'PAID',
      bookingContainers: [],
      cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] },
    };

    wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
      if (where?.code === 'NW5') return [nw5];
      return [nw5, cw3];
    });
    bookingsRepository.findByIdsForScheduling.mockResolvedValue([containerBooking, bulkBooking]);
    trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
    locomotivesRepository.findAll.mockResolvedValue([locomotive]);

    const result = await service.previewTrainSchedule({
      bookingIds: ['c1', 'b1'],
      scheduleDate: '2026-06-20T08:00:00.000Z',
      originStationId: 'yard-origin',
      destinationStationId: 'yard-destination',
    });

    expect(result.valid).toBe(true);
    // Mixed freight now labels the summary by the concrete wagon type codes it uses.
    expect(result.summary.wagonType).toContain('NW5');
    expect(result.summary.wagonType).toContain('CW3');
    expect(result.wagonPlan.length).toBeGreaterThan(2);
    expect(result.containerUnits).toHaveLength(2);
  });

  it('previews container bookings without requiring placements', async () => {
    const bookings = [makeBooking('c2', 'BKG-CONT-2', 50, 1, '40FT', 1)];

    wagonTypesRepository.findAll.mockResolvedValue([nw5]);
    bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
    trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
    locomotivesRepository.findAll.mockResolvedValue([locomotive]);

    const result = await service.previewTrainSchedule({
      bookingIds: ['c2'],
      scheduleDate: '2026-06-20T08:00:00.000Z',
      originStationId: 'yard-origin',
      destinationStationId: 'yard-destination',
    });

    expect(result.valid).toBe(true);
    expect(result.containerUnits).toHaveLength(1);
  });

  it('rejects create when the locked locomotive is no longer available', async () => {
    // Advance scheduling only hard-blocks OUT_OF_SERVICE locomotives; other
    // non-AVAILABLE states (e.g. ASSIGNED) downgrade to a warning.
    const manager = {
      getRepository: jest.fn(() => ({
        findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }),
      })),
    };

    dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
      if (entity?.name === 'Route') {
        return {
          findOne: jest.fn().mockResolvedValue({
            id: 'route-1',
            name: 'Djibouti to Addis',
            originYardId: 'yard-origin',
            destinationYardId: 'yard-destination',
            isActive: true,
            status: 'AVAILABLE',
            direction: 'IMPORT',
          }),
        };
      }
      throw new Error(`Unexpected repository ${entity?.name}`);
    });
    dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
      callback(manager),
    );

    await expect(
      service.createContainerTrainSchedule({
        routeId: 'route-1',
        scheduleDate: '2026-06-20T08:00:00.000Z',
        locomotiveIds: ['loc-1', 'loc-2'],
      }),
    ).rejects.toBeInstanceOf(ConflictException);
  });

  it('rejects pin when wagon is not at the schedule origin yard', async () => {
    const scheduleId = 'sched-1';
    const slotId = 'slot-1';

    trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
      id: scheduleId,
      status: 'DRAFT',
      originStationId: 'yard-origin',
      trainSet: {
        wagons: [{ id: slotId, physicalWagonId: null }],
      },
    });

    const manager = {
      getRepository: jest.fn((entity: { name?: string }) => {
        if (entity === Wagon) {
          return {
            findOne: jest.fn().mockResolvedValue({
              id: 'wagon-1',
              wagonNumber: 'WGN-001',
              status: WagonStatus.Available,
              currentYardId: 'yard-other',
              currentTrainScheduleId: null,
            }),
            update: jest.fn(),
          };
        }
        if (entity === TrainSetWagon) {
          return { update: jest.fn() };
        }
        throw new Error(`Unexpected repository ${entity?.name}`);
      }),
    };
    dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<void>) =>
      callback(manager),
    );

    await expect(
      service.pinWagons(scheduleId, {
        assignments: [{ trainSetWagonId: slotId, physicalWagonId: 'wagon-1' }],
      }),
    ).rejects.toBeInstanceOf(ConflictException);
  });

  it('flags physical fleet shortfall when wagons are not at the origin yard', async () => {
    const exportBooking = makeBooking(
      'exp-1',
      'BKG-EXP',
      50,
      1,
      '40FT',
      1,
      '2026-06-20T08:00:00.000Z',
      'yard-addis',
      'yard-djibouti',
      {
        originYard: { label: 'Addis Ababa', code: 'ADDIS', country: 'Ethiopia' },
        destinationYard: { label: 'Djibouti', code: 'DJIBOUTI', country: 'Djibouti' },
      },
    );

    wagonTypesRepository.findAll.mockResolvedValue([nw5]);
    bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
    trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
    locomotivesRepository.findAll.mockResolvedValue([
      { ...locomotive, currentYardId: 'yard-addis' },
    ]);

    const wrongYardFleet = Array.from({ length: 5 }, (_, index) => ({
      id: `wagon-nw5-${index}`,
      wagonTypeId: nw5.id,
      wagonNumber: `WGN-${index}`,
      status: WagonStatus.Available,
      currentYardId: 'yard-djibouti',
      currentTrainScheduleId: null,
    }));

    dataSource.getRepository.mockImplementation((entity: unknown) => {
      if (entity === TrainSchedulingGlobalRules) {
        return { find: jest.fn().mockResolvedValue([]) };
      }
      if (entity === Wagon) {
        return { find: jest.fn().mockResolvedValue(wrongYardFleet) };
      }
      if (entity === WagonType) {
        return { find: jest.fn().mockResolvedValue([nw5]) };
      }
      return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
    });

    const result = await service.previewContainerTrainSchedule({
      bookingIds: ['exp-1'],
      scheduleDate: '2026-06-20T08:00:00.000Z',
      originStationId: 'yard-addis',
      destinationStationId: 'yard-djibouti',
    });

    // List-based planner: a booking with no plannable wagon at the yard is
    // DEFERRED with the wagon-type reason (assign still hard-fails when no
    // booking fits), instead of surfacing a phantom-slot violation.
    expect(result.valid).toBe(true);
    expect(result.wagonPlan).toHaveLength(0);
    expect(result.deferredBookings.some((d) => d.reason.includes('NW5'))).toBe(true);
  });

  it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {
    const scheduleId = 'sched-assign-1';
    const trainSetId = 'train-set-1';
    const booking = makeBooking('b-pin', 'BKG-PIN', 50, 1, '40FT', 1);

    wagonTypesRepository.findAll.mockResolvedValue([nw5]);
    bookingsRepository.findByIdsForScheduling.mockResolvedValue([{ ...booking, trainScheduleId: scheduleId }]);
    trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
    locomotivesRepository.findAll.mockResolvedValue([locomotive]);
    trainSchedulesRepository.findById.mockResolvedValue({
      id: scheduleId,
      direction: 'IMPORT',
    });
    trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
      id: scheduleId,
      status: 'DRAFT',
      direction: 'IMPORT',
      trainSetId,
      originStationId: 'yard-origin',
      destinationStationId: 'yard-destination',
      scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
      trainSet: {
        id: trainSetId,
        locomotive,
        wagons: [],
      },
      scheduleBookings: [],
    });

    dataSource.getRepository.mockImplementation((entity: unknown) => {
      if (entity === TrainSchedulingGlobalRules) {
        return { find: jest.fn().mockResolvedValue([]) };
      }
      if (entity === Wagon) {
        return { find: jest.fn().mockResolvedValue([]) };
      }
      if (entity === WagonType) {
        return { find: jest.fn().mockResolvedValue([nw5]) };
      }
      return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
    });

    const wagonRepo = {
      find: jest.fn().mockResolvedValue([]),
      update: jest.fn(),
    };
    const trainSetWagonRepo = {
      delete: jest.fn(),
      create: jest.fn((v) => v),
      save: jest.fn(async (rows) =>
        rows.map((r: { sequenceNo: number; wagonTypeId: string }, i: number) => ({
          ...r,
          id: `slot-${i + 1}`,
        })),
      ),
      update: jest.fn(),
    };

    const manager = {
      getRepository: jest.fn((entity: unknown) => {
        if (entity === Wagon) return wagonRepo;
        if (entity === WagonType) return { find: jest.fn().mockResolvedValue([nw5]) };
        if (entity === TrainSetWagon) return trainSetWagonRepo;
        if ((entity as { name?: string })?.name === 'TrainSet') return { update: jest.fn() };
        if ((entity as { name?: string })?.name === 'TrainScheduleBooking') return { delete: jest.fn() };
        if ((entity as { name?: string })?.name === 'WagonBookingAllocation') {
          return {
            create: jest.fn((v) => v),
            save: jest.fn(async (v) => ({ ...v, id: 'alloc-1' })),
            delete: jest.fn(),
          };
        }
        return { delete: jest.fn(), update: jest.fn(), find: jest.fn().mockResolvedValue([]) };
      }),
    };
    dataSource.transaction.mockImplementation(async (cb: (m: typeof manager) => Promise<void>) =>
      cb(manager),
    );

    await expect(
      service.assignBookingsToSchedule(
        scheduleId,
        { bookingIds: ['b-pin'], containerPlacements: [] },
        'CONTAINER',
      ),
    ).rejects.toBeInstanceOf(BadRequestException);
  });

  describe('getUnassignedBookings', () => {
    const scheduleId = 'sched-unassigned-1';
    const trainSetId = 'train-set-unassigned';
    const assignedBooking = makeBooking('b-assigned', 'BKG-ASSIGNED', 50, 1, '40FT', 1);
    const unassignedBooking = makeBooking('b-unassigned', 'BKG-UNASSIGNED', 60, 1, '40FT', 1);

    const buildScheduleGraph = () => ({
      id: scheduleId,
      status: 'DRAFT',
      originStationId: 'yard-origin',
      destinationStationId: 'yard-destination',
      scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
      trainSet: {
        id: trainSetId,
        locomotive: { ...locomotive, status: 'ASSIGNED', currentYardId: 'yard-origin' },
        wagons: [{ id: 'slot-1', sequenceNo: 1, wagonTypeId: nw5.id, allocations: [] }],
      },
      scheduleBookings: [],
    });

    beforeEach(() => {
      wagonTypesRepository.findAll.mockResolvedValue([nw5]);
      trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
      locomotivesRepository.findAll.mockResolvedValue([locomotive]);
      bookingsRepository.findAll.mockResolvedValue([
        {
          ...assignedBooking,
          trainScheduleId: scheduleId,
          paymentStatus: 'PAID',
          isGovernment: false,
        },
        {
          ...unassignedBooking,
          trainScheduleId: scheduleId,
          paymentStatus: 'PAID',
          isGovernment: false,
        },
      ]);
      trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(buildScheduleGraph());
    });

    it('allows assign when train slots are full but origin yard has matching wagons', async () => {
      const yardFleet = [
        {
          id: 'wagon-pinned',
          wagonTypeId: nw5.id,
          status: WagonStatus.Assigned,
          currentYardId: 'yard-origin',
          currentTrainScheduleId: scheduleId,
        },
        ...Array.from({ length: 2 }, (_, index) => ({
          id: `wagon-yard-${index}`,
          wagonTypeId: nw5.id,
          status: WagonStatus.Available,
          currentYardId: 'yard-origin',
          currentTrainScheduleId: null,
        })),
      ];

      bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
        const map = new Map([
          [assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
          [unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
        ]);
        return ids.map((id) => map.get(id)).filter(Boolean);
      });

      dataSource.getRepository.mockImplementation((entity: unknown) => {
        if (entity === TrainSchedulingGlobalRules) {
          return { find: jest.fn().mockResolvedValue([]) };
        }
        if (entity === Wagon) {
          return { find: jest.fn().mockResolvedValue(yardFleet) };
        }
        if (entity === WagonType) {
          return { find: jest.fn().mockResolvedValue([nw5]) };
        }
        if (entity === WagonBookingAllocation) {
          return {
            find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
          };
        }
        return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
      });

      const result = await service.getUnassignedBookings(scheduleId);

      expect(result.bookings).toHaveLength(1);
      expect(result.bookings[0].id).toBe(unassignedBooking.id);
      expect(result.bookings[0].canAssign).toBe(true);
      expect(result.bookings[0].blockReason).toBeNull();
      expect(
        result.fleetAtOrigin.some(
          (row: { wagonTypeCode: string; available: number }) =>
            row.wagonTypeCode === 'NW5' && row.available >= 2,
        ),
      ).toBe(true);
    });

    it('blocks assign when origin yard lacks wagons of the required type', async () => {
      const yardFleet = [
        {
          id: 'wagon-pinned',
          wagonTypeId: nw5.id,
          status: WagonStatus.Assigned,
          currentYardId: 'yard-origin',
          currentTrainScheduleId: scheduleId,
        },
      ];

      bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
        const map = new Map([
          [assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
          [unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
        ]);
        return ids.map((id) => map.get(id)).filter(Boolean);
      });

      dataSource.getRepository.mockImplementation((entity: unknown) => {
        if (entity === TrainSchedulingGlobalRules) {
          return { find: jest.fn().mockResolvedValue([]) };
        }
        if (entity === Wagon) {
          return { find: jest.fn().mockResolvedValue(yardFleet) };
        }
        if (entity === WagonType) {
          return { find: jest.fn().mockResolvedValue([nw5]) };
        }
        if (entity === WagonBookingAllocation) {
          return {
            find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
          };
        }
        return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
      });

      const result = await service.getUnassignedBookings(scheduleId);

      expect(result.bookings).toHaveLength(1);
      expect(result.bookings[0].canAssign).toBe(false);
      expect(result.bookings[0].blockReason).toBeTruthy();
    });
  });

  describe('getAvailableLocomotivesForRoute', () => {
    it('returns every in-service locomotive, annotated with origin-yard presence', async () => {
      const routeId = 'route-export';
      const originYardId = 'yard-addis';
      const routeRepo = {
        findOne: jest.fn().mockResolvedValue({
          id: routeId,
          name: 'Addis → Djibouti',
          isActive: true,
          status: 'AVAILABLE',
          originYardId,
          originYard: { country: 'Ethiopia' },
          destinationYard: { country: 'Djibouti' },
        }),
      };
      dataSource.getRepository.mockImplementation((entity: unknown) => {
        if ((entity as { name?: string })?.name === 'Route') return routeRepo;
        return { findOne: jest.fn(), update: jest.fn() };
      });
      // Advance-scheduling picker: nothing is filtered by yard — every in-service
      // locomotive is returned and annotated with whether it's at the origin yet.
      locomotivesRepository.findAll.mockResolvedValue([
        { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
        { id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' },
      ]);

      const result = await service.getAvailableLocomotivesForRoute(routeId);

      expect(result).toHaveLength(2);
      expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true);
      expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false);
    });

    it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => {
      const routeId = 'route-domestic';
      const originYardId = 'yard-addis';
      const routeRepo = {
        findOne: jest.fn().mockResolvedValue({
          id: routeId,
          name: 'Addis → Dire Dawa',
          isActive: true,
          status: 'AVAILABLE',
          originYardId,
          originYard: { country: 'Ethiopia' },
          destinationYard: { country: 'Ethiopia' },
        }),
      };
      dataSource.getRepository.mockImplementation((entity: unknown) => {
        if ((entity as { name?: string })?.name === 'Route') return routeRepo;
        return { findOne: jest.fn(), update: jest.fn() };
      });

      await expect(
        service.getAvailableLocomotivesForRoute(routeId),
      ).rejects.toBeInstanceOf(BadRequestException);
    });
  });

  describe('marshalling documents', () => {
    // Staff check these against the physical consist, so every wagon on the
    // train set has to appear — an empty wagon that renders no row reads as a
    // wagon that is not on the train.
    const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
      sequenceNo,
      wagonNumber,
      physicalWagon: { wagonNumber },
      wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
      lengthMeters: 14,
      capacityTons: 70,
      allocations,
    });

    const loadedAllocation = {
      bookingId: 'booking-1',
      bookingReference: 'BK-2026-000001',
      loadType: 'CONTAINER',
      allocatedWeightTons: 24.5,
      containerNumbers: ['CONT-001'],
      booking: { id: 'booking-1', reference: 'BK-2026-000001', companyId: 'company-1' },
      containerItems: [{ containerNumber: 'CONT-001', sealNumber: 'SEAL-1', chassisNumber: 'CH-1' }],
    };

    const countRows = (html: string) => (html.match(/<tr(?: class="empty")?>\s*<td/g) ?? []).length;

    it('lists an empty wagon on the export document and marks it EMPTY', () => {
      const schedule = {
        id: 'schedule-1',
        trainNumber: '8302',
        direction: 'EXPORT',
        trainSet: {
          wagons: [
            makeWagon(1, 'W-001', [loadedAllocation]),
            makeWagon(2, 'W-002', []),
            makeWagon(3, 'W-003', []),
          ],
        },
        scheduleBookings: [],
      };

      const html = (service as never as {
        buildExportLoadListHtml: (s: unknown) => string;
      }).buildExportLoadListHtml(schedule);

      expect(countRows(html)).toBe(3);
      expect(html).toContain('W-002');
      expect(html).toContain('W-003');
      expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(2);
      // The wagon count must agree with the rows the reader can see.
      expect(html).toContain('3 (2 empty)');
    });

    it('lists an empty wagon on the import document and marks it EMPTY', () => {
      const loadList = {
        generatedAt: '2026-07-17T08:00:00.000Z',
        trainScheduleId: 'schedule-1',
        trainNumber: '8002',
        route: 'Djibouti → Indode',
        origin: 'Djibouti Port',
        destination: 'Indode',
        totalBookings: 1,
        wagons: [
          { sequenceNo: 1, wagonNumber: 'W-001', allocations: [loadedAllocation] },
          { sequenceNo: 2, wagonNumber: 'W-002', allocations: [] },
        ],
        operation: { status: {} },
      };

      const html = (service as never as {
        buildImportLoadListHtml: (l: unknown) => string;
      }).buildImportLoadListHtml(loadList);

      expect(countRows(html)).toBe(2);
      expect(html).toContain('W-002');
      expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
      expect(html).toContain('2 (1 empty)');
    });

    it('renders wagons in consist order regardless of the order the relation returns', () => {
      const schedule = {
        id: 'schedule-1',
        trainNumber: '8302',
        direction: 'EXPORT',
        trainSet: {
          wagons: [makeWagon(3, 'W-003', []), makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', [])],
        },
        scheduleBookings: [],
      };

      const html = (service as never as {
        buildExportLoadListHtml: (s: unknown) => string;
      }).buildExportLoadListHtml(schedule);

      expect(html.indexOf('W-001')).toBeLessThan(html.indexOf('W-002'));
      expect(html.indexOf('W-002')).toBeLessThan(html.indexOf('W-003'));
    });

    it('omits the empty-count suffix when every wagon is loaded', () => {
      const schedule = {
        id: 'schedule-1',
        trainNumber: '8302',
        direction: 'EXPORT',
        trainSet: { wagons: [makeWagon(1, 'W-001', [loadedAllocation])] },
        scheduleBookings: [],
      };

      const html = (service as never as {
        buildExportLoadListHtml: (s: unknown) => string;
      }).buildExportLoadListHtml(schedule);

      expect(html).not.toContain('empty)');
      expect(html).not.toContain('EMPTY');
    });
  });

  describe('moveWagonLoad — staff rearrange', () => {
    const containerType = {
      code: 'NX70',
      supportedLoadTypes: ['CONTAINER'],
      supportsContainer: true,
    };
    let slotA: Record<string, unknown>;
    let slotB: Record<string, unknown>;
    let allocsByWagon: Record<string, Array<Record<string, unknown>>>;
    let allocRepo: { find: jest.Mock; update: jest.Mock };
    let slotRepo: {
      update: jest.Mock;
      create: jest.Mock;
      save: jest.Mock;
      createQueryBuilder: jest.Mock;
    };
    let wagonRepo: { findOne: jest.Mock };

    const makeSchedule = (over: Record<string, unknown> = {}) => ({
      id: 'sched-1',
      status: 'SCHEDULED',
      trainSetId: 'ts-1',
      trainSet: { trainId: 'train-1', wagons: [slotA, slotB] },
      ...over,
    });

    beforeEach(() => {
      slotA = {
        id: 'wA',
        trainSetId: 'ts-1',
        sequenceNo: 1,
        capacityTons: 61,
        lengthMeters: 14,
        assignedWeightTons: 40,
        status: 'RESERVED',
        boardYardId: 'yard-1',
        alightYardId: null,
        wagonType: containerType,
      };
      slotB = {
        id: 'wB',
        trainSetId: 'ts-1',
        sequenceNo: 2,
        capacityTons: 61,
        lengthMeters: 14,
        assignedWeightTons: 25,
        status: 'RESERVED',
        boardYardId: null,
        alightYardId: null,
        wagonType: containerType,
      };
      allocsByWagon = {
        // 20ft pair (two allocations sharing wagon A) — must travel together.
        wA: [
          { id: 'alloc-a1', trainSetWagonId: 'wA', bookingId: 'b1', allocatedWeightTons: 20, loadType: 'CONTAINER' },
          { id: 'alloc-a2', trainSetWagonId: 'wA', bookingId: 'b2', allocatedWeightTons: 20, loadType: 'CONTAINER' },
        ],
        // one 40ft on wagon B.
        wB: [
          { id: 'alloc-b1', trainSetWagonId: 'wB', bookingId: 'b3', allocatedWeightTons: 25, loadType: 'CONTAINER' },
        ],
      };

      trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(makeSchedule());
      allocRepo = {
        find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) =>
          Promise.resolve(allocsByWagon[where.trainSetWagonId] ?? []),
        ),
        update: jest.fn().mockResolvedValue(undefined),
      };
      slotRepo = {
        update: jest.fn().mockResolvedValue(undefined),
        create: jest.fn((row: Record<string, unknown>) => row),
        save: jest.fn((row: Record<string, unknown>) =>
          Promise.resolve({ id: 'slot-new', ...row }),
        ),
        createQueryBuilder: jest.fn(() => ({
          select: jest.fn().mockReturnThis(),
          where: jest.fn().mockReturnThis(),
          getRawOne: jest.fn().mockResolvedValue({ maxSequenceNo: 2 }),
        })),
      };
      wagonRepo = { findOne: jest.fn().mockResolvedValue(null) };
      dataSource.getRepository.mockImplementation((entity: unknown) => {
        if (entity === WagonBookingAllocation) return allocRepo;
        if (entity === TrainSetWagon) return slotRepo;
        if (entity === Wagon) return wagonRepo;
        return { find: jest.fn().mockResolvedValue([]) };
      });
      dataSource.transaction.mockImplementation(
        async (fn: (m: unknown) => Promise<void>) =>
          fn({ getRepository: dataSource.getRepository }),
      );
      jest
        .spyOn(
          service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
          'getTrainScheduleById' as never,
        )
        .mockResolvedValue({ id: 'sched-1' } as never);
    });

    it('rejects moves on a dispatched train', async () => {
      trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
        makeSchedule({ status: 'DISPATCHED' }),
      );
      await expect(
        service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
      ).rejects.toThrow(BadRequestException);
      expect(dataSource.transaction).not.toHaveBeenCalled();
    });

    it('404s when the target is neither a slot nor a consist wagon of this train', async () => {
      await expect(
        service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'nope' }),
      ).rejects.toThrow(/not part of this schedule/);
    });

    it('swaps two loaded wagons: every allocation crosses over, load fields swap', async () => {
      await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' });

      // The 20ft pair moved together onto wagon B…
      expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' });
      expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'wB' });
      // …and the 40ft came back to wagon A.
      expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' });
      // Load-coupled slot fields follow their loads.
      expect(slotRepo.update).toHaveBeenCalledWith('wB', {
        assignedWeightTons: 40,
        status: 'RESERVED',
        boardYardId: 'yard-1',
        alightYardId: null,
      });
      expect(slotRepo.update).toHaveBeenCalledWith('wA', {
        assignedWeightTons: 25,
        status: 'RESERVED',
        boardYardId: null,
        alightYardId: null,
      });
    });

    it('moves the load onto an empty consist-only wagon without renaming wagons', async () => {
      wagonRepo.findOne.mockResolvedValue({
        id: 'phys-9',
        wagonTypeId: 'wt-1',
        wagonNumber: 'WGN-9',
        wagonType: { ...containerType, capacityTons: 70, lengthMeters: 14 },
      });

      await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-9' });

      expect(wagonRepo.findOne).toHaveBeenCalledWith(
        expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }),
      );
      // A slot is created ON the target wagon, carrying the source's load
      // fields. sequence_no appends past the existing max so it clears the
      // (train_set_id, sequence_no) unique index.
      expect(slotRepo.save).toHaveBeenCalledWith(
        expect.objectContaining({
          trainSetId: 'ts-1',
          physicalWagonId: 'phys-9',
          wagonTypeId: 'wt-1',
          sequenceNo: 3,
          capacityTons: 70,
          lengthMeters: 14,
          assignedWeightTons: 40,
          status: 'RESERVED',
          boardYardId: 'yard-1',
          alightYardId: null,
        }),
      );
      // The whole load crosses onto that new slot…
      expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'slot-new' });
      expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'slot-new' });
      // …and the source wagon stays itself, just empty.
      expect(slotRepo.update).toHaveBeenCalledWith('wA', {
        assignedWeightTons: 0,
        status: 'PLANNED',
        boardYardId: null,
        alightYardId: null,
      });
      // The bug this replaced: the source slot must NOT be repinned to another
      // physical wagon — that reorders the train instead of moving the load.
      expect(slotRepo.update).not.toHaveBeenCalledWith(
        'wA',
        expect.objectContaining({ physicalWagonId: expect.anything() }),
      );
    });

    it('reuses the existing slot when the target wagon is addressed by wagon id', async () => {
      // wB is already pinned to physical wagon phys-B. Addressing that wagon
      // directly must land in wB, not mint a second slot on the same wagon.
      (slotB as Record<string, unknown>).physicalWagonId = 'phys-B';
      wagonRepo.findOne.mockResolvedValue({
        id: 'phys-B',
        wagonTypeId: 'wt-1',
        wagonNumber: 'WGN-B',
        wagonType: containerType,
      });

      await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-B' });

      expect(slotRepo.save).not.toHaveBeenCalled();
      expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' });
      expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' });
    });

    it('rejects a bulk load onto a wagon whose type only supports containers', async () => {
      allocsByWagon.wA = [
        { id: 'alloc-bulk', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 50, loadType: 'BULK' },
      ];
      allocsByWagon.wB = [];

      await expect(
        service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
      ).rejects.toThrow(/cannot carry a bulk load/);
    });

    it('rejects when the incoming load exceeds the receiving wagon payload', async () => {
      allocsByWagon.wA = [
        { id: 'alloc-heavy', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 70, loadType: 'CONTAINER' },
      ];
      allocsByWagon.wB = [];

      await expect(
        service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
      ).rejects.toThrow(/over its/);
    });
  });
});
