/**
 * Train capacity is a THREE-AXIS constraint, and the axes are not interchangeable:
 *
 *   count   — how many wagons fit end to end on the longest allowed train
 *   length  — Σ wagonType.lengthMeters over the real consist
 *   weight  — Σ (wagonType.tareWeightTons + cargoTons) over the real consist
 *
 * The weight axis is GROSS: a locomotive pulls the wagon as well as what is in it.
 * The old code compared the locomotive's pull limit against cargo payload alone
 * and so overbooked every train by roughly the tare fraction (~27% on PW2).
 *
 * The weight axis is also driven by ACTUAL booked cargo, never by an assumed
 * full payload. That is what makes the real EDR numbers fall out:
 *
 *   NW5  13.966m tare 22.4T → 760 / 13.966 = 54 slots by length; the 53-wagon
 *        marshalling figure is length-bound, and those trains never carry 53×70T.
 *   PW2  17.066m tare 25.2T → 44 slots by length, but 37 × (25.2 + 70) = 3522.4T,
 *        which clears 3500T only via the locomotive's overage tolerance. Weight
 *        binds first, hence "37 wagons per train".
 *
 * So: `maxWagonSlots` is a LENGTH-derived planning number, shown before any cargo
 * exists. Weight is enforced against the consist as bookings are allocated.
 */

/** Physical dimensions used when deriving how many wagons a locomotive can pull. */
export type WagonTypeDimensions = {
  lengthMeters: number;
  capacityTons: number;
  tareWeightTons: number;
};

/** One occupied wagon slot in a real consist. */
export type ConsistSlot = {
  lengthMeters: number;
  tareWeightTons: number;
  /** Actual cargo/container weight riding on this wagon, not its rated capacity. */
  cargoTons: number;
};

export type LocomotiveLimits = {
  maxPullWeightTons: number;
  maxTrainLengthMeters: number;
  /** Allowed deviation above maxPullWeightTons before scheduling blocks the train. */
  overageToleranceTons?: number | null;
  /** Allowed deviation above maxTrainLengthMeters before scheduling blocks the train. */
  overageToleranceMeters?: number | null;
};

export type DerivedTrainCapacity = {
  /** Gross (tare + cargo) tons the train may weigh, tolerance included. */
  maxWeightTons: number;
  maxLengthMeters: number;
  /** Length-derived slot count. Weight is enforced separately against real cargo. */
  maxWagonSlots: number;
  /** Caps WITHOUT the overage tolerance — what batch filling budgets against. */
  baseWeightTons: number;
  baseLengthMeters: number;
  /** Overage spendable only by admitting a booking whole, never by a split. */
  toleranceTons: number;
  toleranceMeters: number;
};

/** What a consist currently uses, and what is left on each axis. */
export type ConsistUsage = {
  wagonCount: number;
  usedLengthMeters: number;
  /** Σ (tare + cargo). */
  usedGrossWeightTons: number;
  usedTareWeightTons: number;
  usedCargoWeightTons: number;
  remainingLengthMeters: number;
  remainingGrossWeightTons: number;
  remainingWagons: number;
};

export const MAX_FALLBACK_WEIGHT = 3500;
export const MAX_FALLBACK_LENGTH = 760;

const DEFAULT_WAGON_LENGTH_M = 14;
const DEFAULT_WAGON_CAPACITY_T = 70;
/** NW5's tare — the commonest wagon — used only when a type predates the NOT NULL backfill. */
const DEFAULT_WAGON_TARE_T = 22.4;

function num(value: unknown, fallback = 0): number {
  const n = Number(value);
  return Number.isFinite(n) ? n : fallback;
}

/**
 * Cargo tons of a booking: the stored VGM total when present, else the sum of
 * its container lines (quantity × VGM per unit). The portal's container flow
 * stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the
 * total alone made every such booking weigh only its tare.
 */
export function bookingCargoTons(booking: {
  cargoTotalWeightVgm?: number | string | null;
  bookingContainers?: Array<{
    quantity?: number | null;
    vgmPerUnitTons?: number | string | null;
  }> | null;
}): number {
  const total = num(booking.cargoTotalWeightVgm);
  if (total > 0) return total;
  return (booking.bookingContainers ?? []).reduce(
    (sum, line) => sum + num(line.quantity) * num(line.vgmPerUnitTons),
    0,
  );
}

/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
  return num(slot.tareWeightTons) + num(slot.cargoTons);
}

/**
 * Hard caps for a train: the locomotive's own limits, floored by the global rule
 * caps, then widened by the locomotive's overage tolerance.
 *
 * `base*` are the caps BEFORE the tolerance is added. The tolerance is not
 * general-purpose headroom: batch filling budgets against the base caps and may
 * spend the tolerance only to admit a booking WHOLE (never to size a split), so
 * both figures are returned. `base + tolerance === max` always holds, including
 * the fallback path.
 */
export function trainHardCaps(
  locomotive: LocomotiveLimits,
  ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
): {
  maxWeightTons: number;
  maxLengthMeters: number;
  baseWeightTons: number;
  baseLengthMeters: number;
  toleranceTons: number;
  toleranceMeters: number;
} {
  const overageTons = num(locomotive.overageToleranceTons);
  const overageMeters = num(locomotive.overageToleranceMeters);

  const baseWeight = Math.min(
    num(locomotive.maxPullWeightTons, Infinity) || Infinity,
    ruleCaps?.maxTrainWeightTons ?? Infinity,
  );
  const baseLength = Math.min(
    num(locomotive.maxTrainLengthMeters, Infinity) || Infinity,
    ruleCaps?.maxTrainLengthMeters ?? Infinity,
  );

  const baseWeightTons = Number.isFinite(baseWeight) ? baseWeight : MAX_FALLBACK_WEIGHT;
  const baseLengthMeters = Number.isFinite(baseLength) ? baseLength : MAX_FALLBACK_LENGTH;
  const toleranceTons = Number.isFinite(baseWeight) ? overageTons : 0;
  const toleranceMeters = Number.isFinite(baseLength) ? overageMeters : 0;

  return {
    maxWeightTons: baseWeightTons + toleranceTons,
    maxLengthMeters: baseLengthMeters + toleranceMeters,
    baseWeightTons,
    baseLengthMeters,
    toleranceTons,
    toleranceMeters,
  };
}

/**
 * Derive the planning capacity of a train from its locomotive.
 *
 * `maxWagonSlots` counts how many of the SHORTEST allowed wagon type fit within
 * the train-length cap — the optimistic slot count, since a mixed consist of
 * longer wagons will hit the length cap sooner. It is deliberately NOT reduced by
 * weight: with no bookings yet there is no cargo, and assuming every wagon rides
 * at full rated payload would report 37 NW5 slots where the railway marshals 53.
 * Weight is enforced by {@link consistUsage} / {@link consistViolations} against
 * the cargo actually allocated.
 */
export function deriveTrainCapacityFromLocomotive(
  locomotive: LocomotiveLimits,
  wagonTypes: WagonTypeDimensions[],
  ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
): DerivedTrainCapacity {
  const caps = trainHardCaps(locomotive, ruleCaps);

  const lengths = wagonTypes
    .map((w) => num(w.lengthMeters))
    .filter((l) => l > 0);
  const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M;

  const maxWagonSlots =
    minLength > 0 ? Math.max(0, Math.floor(caps.maxLengthMeters / minLength)) : 0;

  return { ...caps, maxWagonSlots };
}

/**
 * What a real, mixed-type consist uses on all three axes, and what is left.
 * Every wagon contributes its own length and its own tare — no averaging over a
 * representative wagon type.
 */
export function consistUsage(
  slots: ConsistSlot[],
  caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number },
): ConsistUsage {
  let usedLengthMeters = 0;
  let usedTareWeightTons = 0;
  let usedCargoWeightTons = 0;

  for (const slot of slots) {
    usedLengthMeters += num(slot.lengthMeters);
    usedTareWeightTons += num(slot.tareWeightTons);
    usedCargoWeightTons += num(slot.cargoTons);
  }

  const usedGrossWeightTons = usedTareWeightTons + usedCargoWeightTons;

  return {
    wagonCount: slots.length,
    usedLengthMeters: round3(usedLengthMeters),
    usedGrossWeightTons: round3(usedGrossWeightTons),
    usedTareWeightTons: round3(usedTareWeightTons),
    usedCargoWeightTons: round3(usedCargoWeightTons),
    remainingLengthMeters: round3(caps.maxLengthMeters - usedLengthMeters),
    remainingGrossWeightTons: round3(caps.maxWeightTons - usedGrossWeightTons),
    remainingWagons: caps.maxWagonSlots - slots.length,
  };
}

/** Human-readable reasons a consist breaks its train's limits. Empty = it fits. */
export function consistViolations(
  slots: ConsistSlot[],
  caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number },
): string[] {
  const usage = consistUsage(slots, caps);
  const violations: string[] = [];

  if (usage.usedGrossWeightTons > caps.maxWeightTons) {
    violations.push(
      `Total train gross weight ${usage.usedGrossWeightTons}T ` +
        `(${usage.usedTareWeightTons}T tare + ${usage.usedCargoWeightTons}T cargo) ` +
        `exceeds max pull weight ${round3(caps.maxWeightTons)}T`,
    );
  }
  if (usage.usedLengthMeters > caps.maxLengthMeters) {
    violations.push(
      `Total wagon length ${usage.usedLengthMeters}m exceeds max train length ${round3(caps.maxLengthMeters)}m`,
    );
  }
  if (usage.wagonCount > caps.maxWagonSlots) {
    violations.push(
      `Wagon count ${usage.wagonCount} exceeds max wagons per train (${caps.maxWagonSlots})`,
    );
  }

  return violations;
}

function round3(value: number): number {
  return Number.isFinite(value) ? Number(value.toFixed(3)) : value;
}

/**
 * Effective pull limits for a train set with multiple locomotives: the weakest
 * locomotive caps the train, so take the minimum pull weight and minimum length
 * across all assigned locomotives. Returns null when no locomotives are given.
 */
export function minLocomotiveLimits(
  locomotives: Array<
    Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
      Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
  >,
): LocomotiveLimits | null {
  if (!locomotives.length) return null;
  return {
    maxPullWeightTons: Math.min(
      ...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity),
    ),
    maxTrainLengthMeters: Math.min(
      ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
    ),
    // Weakest CONFIGURED tolerance governs the set — a locomotive with no
    // tolerance set has no opinion, it does not zero out the others.
    overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)),
    overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)),
  };
}

function minConfigured(values: Array<number | null | undefined>): number {
  const configured = values.filter((v) => v != null).map((v) => num(v));
  return configured.length ? Math.min(...configured) : 0;
}

/**
 * Effective limits for a whole train set: min across its linked locomotives,
 * falling back to the legacy single `locomotive` column for sets created
 * before multi-loco support. Null when the set has no locomotive at all.
 */
export function trainSetLocomotiveLimits(
  trainSet?: {
    locomotive?: LocomotiveLimits | null;
    locomotives?: Array<{ locomotive?: LocomotiveLimits | null }> | null;
  } | null,
): LocomotiveLimits | null {
  if (!trainSet) return null;
  const linked = (trainSet.locomotives ?? [])
    .map((link) => link.locomotive)
    .filter((l): l is LocomotiveLimits => Boolean(l));
  const pool = linked.length
    ? linked
    : trainSet.locomotive
      ? [trainSet.locomotive]
      : [];
  return minLocomotiveLimits(pool);
}

/** Per-booking train length from wagon count and freight-specific wagon type length. */
export function bookingTrainLengthMeters(
  freightType: string | null | undefined,
  wagonCount: number,
  lengths: { container: number; bulk: number },
): number {
  const perWagon = freightType === 'BULK' ? lengths.bulk : lengths.container;
  return wagonCount * perWagon;
}

/**
 * Gross weight a booking adds to its train: its cargo plus the tare of every
 * wagon it occupies. A booking is never weightless just because it is light —
 * the empty wagons still have to be pulled.
 */
export function bookingGrossWeightTons(
  cargoTons: number,
  wagonCount: number,
  tarePerWagonTons: number,
): number {
  return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons));
}

/**
 * Size a partial (split-on-payment) offer against the room left on a train,
 * across ALL THREE capacity axes — not just wagon slots. Each wagon adds
 * `capacityTons` of payload headroom but its own tare spends the same weight
 * room the cargo needs, so on a weight-limited train more wagons is not always
 * more cargo. Scans wagon counts (the last wagon may run part-loaded) and
 * returns the count that maximizes the cargo carried, with the cargo cap the
 * caller should apply. Null when not even one part-loaded wagon fits. The
 * offer is a strict subset of the booking: never all `bookingWagons`.
 *
 * `fullWagonsOnly` (bulk): every offered wagon rides at its full rated payload,
 * so each wagon costs `capacityTons + tareWeightTons` of gross weight room and
 * the offer is the largest whole-wagon count whose gross fits — never a
 * part-loaded last wagon squeezed into leftover pull weight.
 */
export function sizePartialOfferWagons(
  room: { wagons: number; weightTons: number; lengthMeters: number },
  bookingWagons: number,
  perWagon: { capacityTons: number; tareWeightTons: number; lengthMeters: number },
  opts?: { fullWagonsOnly?: boolean },
): { wagons: number; maxCargoTons: number } | null {
  const maxByLength =
    perWagon.lengthMeters > 0
      ? Math.floor(room.lengthMeters / perWagon.lengthMeters)
      : room.wagons;
  const ceiling = Math.min(room.wagons, maxByLength, bookingWagons - 1);

  if (opts?.fullWagonsOnly) {
    const grossPerWagon = perWagon.capacityTons + perWagon.tareWeightTons;
    const maxByWeight =
      grossPerWagon > 0 ? Math.floor(room.weightTons / grossPerWagon) : 0;
    const wagons = Math.min(ceiling, maxByWeight);
    if (wagons < 1) return null;
    return { wagons, maxCargoTons: round3(wagons * perWagon.capacityTons) };
  }

  let wagons = 0;
  let bestCargoTons = 0;
  for (let w = 1; w <= ceiling; w += 1) {
    const cargoAt = Math.min(
      w * perWagon.capacityTons,
      room.weightTons - w * perWagon.tareWeightTons,
    );
    if (cargoAt > bestCargoTons) {
      bestCargoTons = cargoAt;
      wagons = w;
    }
  }
  if (wagons < 1) return null;
  return { wagons, maxCargoTons: round3(room.weightTons - wagons * perWagon.tareWeightTons) };
}

export function wagonTypeDimensionsFromEntity(wt: {
  lengthMeters?: number | string | null;
  capacityTons?: number | string | null;
  tareWeightTons?: number | string | null;
}): WagonTypeDimensions {
  return {
    lengthMeters: num(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
    capacityTons: num(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
    tareWeightTons: num(wt.tareWeightTons) || DEFAULT_WAGON_TARE_T,
  };
}
