import {
  bookingGrossWeightTons,
  bookingTrainLengthMeters,
  consistUsage,
  consistViolations,
  deriveTrainCapacityFromLocomotive,
  grossWagonWeightTons,
  minLocomotiveLimits,
  sizePartialOfferWagons,
  trainSetLocomotiveLimits,
} from './train-capacity.util';

describe('train-capacity.util', () => {
  // Real EDR wagon specs.
  const nw5 = { lengthMeters: 13.966, capacityTons: 70, tareWeightTons: 22.4 };
  const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 };
  const gw2 = { lengthMeters: 12.228, capacityTons: 70, tareWeightTons: 23 };

  const caps = (over = {}) => ({
    maxWeightTons: 3500,
    maxLengthMeters: 760,
    maxWagonSlots: 54,
    ...over,
  });

  const slots = (n: number, type: typeof nw5, cargoTons: number) =>
    Array.from({ length: n }, () => ({
      lengthMeters: type.lengthMeters,
      tareWeightTons: type.tareWeightTons,
      cargoTons,
    }));

  describe('deriveTrainCapacityFromLocomotive', () => {
    it('derives wagon slots from train length, not a fixed 53', () => {
      const shortLoco = deriveTrainCapacityFromLocomotive(
        { maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
        [nw5],
      );
      expect(shortLoco.maxWagonSlots).toBe(20); // floor(280 / 13.966)
      expect(shortLoco.maxWagonSlots).not.toBe(53);
    });

    it('does not shrink slots by assuming every wagon rides at full payload', () => {
      // A 2100T loco could only pull 30 fully-laden 70T wagons, but slots are a
      // LENGTH figure — the cargo that decides weight does not exist yet.
      const derived = deriveTrainCapacityFromLocomotive(
        { maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
        [nw5],
      );
      expect(derived.maxWagonSlots).toBe(54); // floor(760 / 13.966), not 30
      expect(derived.maxWeightTons).toBe(2100);
    });

    it('admits the railway 53-wagon NW5 marshalling figure', () => {
      const derived = deriveTrainCapacityFromLocomotive(
        { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
        [nw5],
      );
      expect(derived.maxWagonSlots).toBeGreaterThanOrEqual(53);
    });

    it('uses the shortest wagon type when mixed types are present', () => {
      const mixed = deriveTrainCapacityFromLocomotive(
        { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
        [nw5, pw2, gw2],
      );
      expect(mixed.maxWagonSlots).toBe(Math.floor(760 / gw2.lengthMeters)); // 62
    });

    it('extends weight/length caps by the locomotive overage tolerance', () => {
      const derived = deriveTrainCapacityFromLocomotive(
        { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
        [pw2],
      );
      expect(derived.maxWeightTons).toBe(3590);
    });

    it('reports the base caps and tolerance separately so filling can budget on base', () => {
      const derived = deriveTrainCapacityFromLocomotive(
        {
          maxPullWeightTons: 3500,
          maxTrainLengthMeters: 760,
          overageToleranceTons: 90,
          overageToleranceMeters: 20,
        },
        [pw2],
      );
      expect(derived.baseWeightTons).toBe(3500);
      expect(derived.baseLengthMeters).toBe(760);
      expect(derived.toleranceTons).toBe(90);
      expect(derived.toleranceMeters).toBe(20);
      expect(derived.baseWeightTons + derived.toleranceTons).toBe(derived.maxWeightTons);
      expect(derived.baseLengthMeters + derived.toleranceMeters).toBe(derived.maxLengthMeters);
    });

    it('ignores overage tolerance when unset (strict cap)', () => {
      const derived = deriveTrainCapacityFromLocomotive(
        { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
        [nw5],
      );
      expect(derived.maxWeightTons).toBe(3500);
      expect(derived.maxLengthMeters).toBe(760);
    });

    it('floors the locomotive by the global rule caps', () => {
      const derived = deriveTrainCapacityFromLocomotive(
        { maxPullWeightTons: 5000, maxTrainLengthMeters: 900 },
        [nw5],
        { maxTrainWeightTons: 3500, maxTrainLengthMeters: 760 },
      );
      expect(derived.maxWeightTons).toBe(3500);
      expect(derived.maxLengthMeters).toBe(760);
    });
  });

  describe('gross weight', () => {
    it('counts the wagon as well as its cargo', () => {
      expect(grossWagonWeightTons({ tareWeightTons: 25.2, cargoTons: 70 })).toBe(95.2);
    });

    it('charges a booking one tare per wagon it occupies', () => {
      // 3 flat wagons carrying 100T of cargo still drag 3 × 22.4T of steel.
      expect(bookingGrossWeightTons(100, 3, 22.4)).toBe(167.2);
    });

    it('is cargo alone when the wagon type has no tare on record', () => {
      expect(bookingGrossWeightTons(100, 3, 0)).toBe(100);
    });
  });

  describe('consistUsage', () => {
    it('sums each wagon own length and tare rather than averaging a type', () => {
      const mixed = [...slots(2, nw5, 10), ...slots(1, pw2, 20)];
      const usage = consistUsage(mixed, caps());

      expect(usage.wagonCount).toBe(3);
      expect(usage.usedLengthMeters).toBe(44.998); // 2×13.966 + 17.066
      expect(usage.usedTareWeightTons).toBe(70); // 2×22.4 + 25.2
      expect(usage.usedCargoWeightTons).toBe(40);
      expect(usage.usedGrossWeightTons).toBe(110);
      expect(usage.remainingGrossWeightTons).toBe(3390);
      expect(usage.remainingWagons).toBe(51);
    });

    it('reports an empty consist as fully available', () => {
      const usage = consistUsage([], caps());
      expect(usage.usedGrossWeightTons).toBe(0);
      expect(usage.remainingLengthMeters).toBe(760);
      expect(usage.remainingWagons).toBe(54);
    });
  });

  describe('consistViolations', () => {
    it('accepts 37 fully-laden PW2 box wagons only via the overage tolerance', () => {
      // 37 × (25.2 + 70) = 3522.4T — over 3500T, inside 3590T.
      const consist = slots(37, pw2, 70);

      expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).toEqual([
        expect.stringContaining('3522.4T'),
      ]);
      expect(
        consistViolations(consist, caps({ maxWeightTons: 3590, maxWagonSlots: 44 })),
      ).toEqual([]);
    });

    it('blocks a train the old cargo-only math would have waved through', () => {
      // Cargo alone is 2590T — comfortably "under" 3500T. Gross is 3522.4T.
      const consist = slots(37, pw2, 70);
      const cargoOnly = consist.reduce((sum, s) => sum + s.cargoTons, 0);

      expect(cargoOnly).toBeLessThan(3500);
      expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).not.toEqual([]);
    });

    it('lets 53 NW5 flat wagons pass when the cargo is what the railway really loads', () => {
      // 53 × 13.966 = 740.2m < 760m; 53 × (22.4 + 40) = 3307.2T < 3500T.
      expect(consistViolations(slots(53, nw5, 40), caps({ maxWagonSlots: 54 }))).toEqual([]);
    });

    it('flags an over-length consist', () => {
      const violations = consistViolations(slots(50, pw2, 5), caps({ maxWagonSlots: 60 }));
      expect(violations).toEqual([expect.stringContaining('exceeds max train length')]);
    });

    it('flags an over-count consist', () => {
      const violations = consistViolations(slots(10, nw5, 1), caps({ maxWagonSlots: 9 }));
      expect(violations).toEqual([expect.stringContaining('exceeds max wagons per train')]);
    });

    it('reports every broken axis at once', () => {
      expect(consistViolations(slots(60, pw2, 70), caps())).toHaveLength(3);
    });
  });

  it('computes booking length by freight type', () => {
    expect(bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28);
    expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
  });

  it('takes the weakest locomotive across a multi-locomotive set', () => {
    const limits = minLocomotiveLimits([
      { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
      { maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 },
    ]);
    expect(limits?.maxPullWeightTons).toBe(3500);
    expect(limits?.overageToleranceTons).toBe(20);
  });

  it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
    // LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
    // keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train.
    const limits = minLocomotiveLimits([
      { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
      { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null },
    ]);
    expect(limits?.overageToleranceTons).toBe(90);
    // All unconfigured → no tolerance.
    const none = minLocomotiveLimits([
      { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
    ]);
    expect(none?.overageToleranceTons).toBe(0);
  });

  it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
    const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
    const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null };
    expect(
      trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
    ).toEqual({
      maxPullWeightTons: 3500,
      maxTrainLengthMeters: 700,
      overageToleranceTons: 90,
      overageToleranceMeters: 0,
    });
    expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500);
    expect(trainSetLocomotiveLimits(null)).toBeNull();
    expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
  });

  describe('sizePartialOfferWagons', () => {
    it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => {
      // The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2
      // wagons: 1000 + 378 tare = 1378), leaving 744T of pull weight but plenty
      // of slots/length. The boundary 1000T booking (15 wagons) must be offered
      // the largest part 744T can carry: 8 wagons whose tare is 201.6T, hauling
      // 542.4T of cargo — gross exactly 744.
      const offer = sizePartialOfferWagons(
        { wagons: 40, weightTons: 744, lengthMeters: 500 },
        15,
        pw2,
      );
      expect(offer).toEqual({ wagons: 8, maxCargoTons: 542.4 });
    });

    it('still sizes by wagon slots when they bind first (legacy behavior)', () => {
      const offer = sizePartialOfferWagons(
        { wagons: 3, weightTons: 100000, lengthMeters: 100000 },
        15,
        pw2,
      );
      expect(offer?.wagons).toBe(3);
    });

    it('sizes by the LENGTH axis when it binds first', () => {
      // 60m of train left → 3 PW2 (17.066m) fit, the 4th does not.
      const offer = sizePartialOfferWagons(
        { wagons: 40, weightTons: 100000, lengthMeters: 60 },
        15,
        pw2,
      );
      expect(offer?.wagons).toBe(3);
    });

    it('never offers all of the booking — a split is a strict subset', () => {
      const offer = sizePartialOfferWagons(
        { wagons: 40, weightTons: 100000, lengthMeters: 100000 },
        15,
        pw2,
      );
      expect(offer?.wagons).toBe(14);
    });

    it('returns null when not even one part-loaded wagon fits the weight room', () => {
      expect(
        sizePartialOfferWagons({ wagons: 5, weightTons: 20, lengthMeters: 500 }, 15, pw2),
      ).toBeNull();
    });

    describe('fullWagonsOnly (bulk)', () => {
      it('offers only whole full wagons — each costs capacity + tare of gross room', () => {
        // 704T of pull weight left. A full PW2 wagon is 70 + 25.2 = 95.2T gross,
        // so 7 fit (666.4T) and the 8th (761.6T) does not. Cargo is exactly
        // 7 × 70 = 490T — the last wagon is never part-loaded into the leftover.
        const offer = sizePartialOfferWagons(
          { wagons: 40, weightTons: 704, lengthMeters: 500 },
          9,
          pw2,
          { fullWagonsOnly: true },
        );
        expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 });
      });

      it('never squeezes a part-loaded wagon into leftover weight room', () => {
        // Same 744T room as the part-load scenario above: the scan would pick
        // 8 wagons hauling 542.4T (last wagon at 52.4/70). Full-wagon sizing
        // stops at 7 fully loaded wagons.
        const offer = sizePartialOfferWagons(
          { wagons: 40, weightTons: 744, lengthMeters: 500 },
          15,
          pw2,
          { fullWagonsOnly: true },
        );
        expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 });
      });

      it('returns null when the room cannot take even one FULL wagon', () => {
        // 67.6T left (3590 cap − 3522.4 boarded): a part-loaded wagon would fit
        // (25.2 tare + 42.4 cargo) but a full one (95.2 gross) does not — the
        // booking must be skipped entirely, not trimmed onto the train.
        expect(
          sizePartialOfferWagons(
            { wagons: 40, weightTons: 67.6, lengthMeters: 500 },
            3,
            pw2,
            { fullWagonsOnly: true },
          ),
        ).toBeNull();
      });
    });
  });
});
