import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';

import {
  NO_MILE_SERVICE_MESSAGE,
  SELF_HAUL_CONFLICT_MESSAGE,
  usesEdrMileService,
} from '../../common/mile-haulage.util';
import { attachMileFinancials } from '../../common/mile-financials.util';
import {
  assertBulkTonnageRemains,
  assertTruckCountWithinContainers,
  assertTruckLoad,
  bookingContainerSizes,
  remainingBulkTons,
} from '../../common/truck-load.util';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
import { LastMileRepository } from './last-mile.repository';
import { FilesService } from '../files/files.service';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';

type LastMileListFilter = {
  status?: LastMileStatus;
  bookingId?: string;
  vehicleId?: string;
  page?: number;
  pageSize?: number;
  sortBy?: string;
  sortOrder?: string;
};

const SORTABLE_FIELDS: (keyof LastMile)[] = [
  'status',
  'advancedPayment',
  'remainingPayment',
  'createdAt',
];

@Injectable()
export class LastMileService {
  private readonly logger = new Logger(LastMileService.name);

  constructor(
    private readonly lastMileRepository: LastMileRepository,
    private readonly bookingsRepository: BookingsRepository,
    private readonly vehiclesService: VehiclesService,
    private readonly driversService: DriversService,
    private readonly smsClient: SmsClientService,
    private readonly dataSource: DataSource,
    private readonly history: FleetHistoryService,
    private readonly billing: BillingService,
    private readonly filesService: FilesService,
  ) {}

  /** Attach real invoice info (number/status) to records so the UI can show an
   *  invoice link only when one actually exists — NOT merely because distance
   *  was entered. Batched to avoid N+1. */
  private async attachInvoices(records: LastMile[]): Promise<void> {
    const invoices = await this.billing.findBySourceIds(
      'last_mile',
      records.map((r) => r.id),
    );
    const byId = new Map<string, { id: string; number: string; status: string }>();
    for (const inv of invoices) {
      if (!byId.has(inv.sourceId)) {
        byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) });
      }
    }
    for (const r of records) {
      (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
    }
    await attachMileFinancials(this.dataSource, records, 'LAST_MILE');
  }

  /** Resolve a vehicle's driver + human labels, for stamping mile events onto
   *  the driver's timeline and naming the vehicle. Best-effort — never throws. */
  private async vehicleInfo(
    vehicleId?: string | null,
  ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> {
    if (!vehicleId) return { driverId: null, plate: null, driverName: null };
    try {
      const v = await this.vehiclesService.findById(vehicleId);
      return {
        driverId: v.assignedDriverId ?? null,
        plate: v.plateNumber ?? v.code ?? null,
        driverName: v.assignedDriverName ?? null,
      };
    } catch {
      return { driverId: null, plate: null, driverName: null };
    }
  }

  /** A leg counts as having a vehicle if it has a direct assignment or at least
   *  one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */
  private async hasAssignedVehicle(
    recordId: string,
    directVehicleId?: string | null,
  ): Promise<boolean> {
    if (directVehicleId) return true;
    const count = await this.dataSource.manager.count(LastMileContainerAllocation, {
      where: { lastMileId: recordId, vehicleId: Not(IsNull()) },
    });
    return count > 0;
  }

  /** Human booking reference for a last-mile record, for the history timeline.
   *  Uses the already-loaded relation when present, else looks it up. */
  private async resolveBookingRef(
    record: LastMile,
  ): Promise<string | null> {
    const loaded = (record as LastMile & { booking?: { reference?: string } })
      .booking?.reference;
    if (loaded) return loaded;
    if (!record.bookingId) return null;
    try {
      const booking = await this.bookingsRepository.findById(record.bookingId);
      return (booking as { reference?: string } | null)?.reference ?? null;
    } catch {
      return null;
    }
  }

  /**
   * Only a booking that actually bought EDR delivery belongs in the last-mile
   * queue, and a booking hauled by the customer's own truck must never also get
   * an EDR leg.
   *
   * Both halves were missing: creation checked payment alone, so any paid
   * booking could be accepted into the queue — including one whose contract
   * chose no road legs at all, and one already carrying a customer truck. The
   * mirror rule existed on the truck side only
   * (CustomerTruckService.assertSelfHaulPaid), so whichever side acted second
   * silently opened a competing delivery on the same booking.
   */
  private async assertEdrHaulsThisBooking(bookingId?: string | null): Promise<void> {
    if (!bookingId) return;

    const [booking] = await this.dataSource.query(
      `SELECT trade_direction           AS "tradeDirection",
              first_mile_pickup_address AS "firstMile",
              last_mile_delivery_address AS "lastMile"
         FROM freight.bookings
        WHERE id = $1 AND deleted_at IS NULL`,
      [bookingId],
    );
    // The road legs are chosen on the contract and copied onto the booking, so
    // the booking's own addresses answer this without a join.
    if (booking && !usesEdrMileService(booking)) {
      throw new BadRequestException(NO_MILE_SERVICE_MESSAGE);
    }

    const [truck] = await this.dataSource.query(
      `SELECT 1
         FROM freight.customer_truck_assignments
        WHERE booking_id = $1 AND deleted_at IS NULL
        LIMIT 1`,
      [bookingId],
    );
    if (truck) {
      throw new BadRequestException(SELF_HAUL_CONFLICT_MESSAGE);
    }
  }

  async acceptBooking(bookingReference: string): Promise<LastMile | null> {
    const booking = await this.bookingsRepository.findByReference(bookingReference);

    if (!booking) {
      return null;
    }

    if (booking.paymentStatus !== 'PAID') {
       return null;
    }

    return this.create({
      bookingId: booking.id,
      advancedPayment: 0,
    });
  }

    async acceptBookingByReference(bookingReference: string): Promise<LastMile | null> {
    const booking = await this.bookingsRepository.findByReference(bookingReference);

    if (!booking) {
      return null;
    }

    if (booking.paymentStatus !== 'PAID') {
            return null;
    }

    
    return this.create({
      bookingId: booking.id,
      advancedPayment: 0,
    });
  }

  async findAll(filter: LastMileListFilter = {}): Promise<{
    data: LastMile[];
    meta: { total: number; page: number; pageSize: number; totalPages: number };
  }> {
    const page = filter.page ?? 1;
    const pageSize = filter.pageSize ?? 50;
    const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof LastMile)
      ? (filter.sortBy as keyof LastMile)
      : 'createdAt';
    const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';

    const where: FindOptionsWhere<LastMile> = {};
    if (filter.status) where.status = filter.status;
    if (filter.bookingId) where.bookingId = filter.bookingId;
    if (filter.vehicleId) where.vehicleId = filter.vehicleId;

    const [data, total] = await this.lastMileRepository.findAndCount({
      where,
      relations: {
        booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
        vehicle: true,
        vehicleAssignments: { vehicle: true, containers: true },
      },
      order: { [sortBy]: sortOrder },
      skip: (page - 1) * pageSize,
      take: pageSize,
    });

    await this.attachInvoices(data);

    return {
      data,
      meta: {
        total,
        page,
        pageSize,
        totalPages: Math.max(1, Math.ceil(total / pageSize)),
      },
    };
  }

  async findById(id: string): Promise<LastMile> {
    const record = await this.lastMileRepository.findById(id, {
      relations: {
        booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
        vehicle: true,
        vehicleAssignments: { vehicle: true, containers: true },
      },
    });

    if (!record) {
      throw new NotFoundException(`Last-mile record ${id} not found`);
    }

    await this.attachInvoices([record]);

    return record;
  }

  /**
   * Record proof of delivery (recipient signature + photos + notes) and complete
   * the leg. Marking DELIVERED reuses {@link update}'s side effects (deliveredAt,
   * vehicle release, history).
   */
  async recordProofOfDelivery(
    id: string,
    dto: RecordProofOfDeliveryDto,
    files: Express.Multer.File[],
  ): Promise<LastMile> {
    const existing = await this.findById(id);

    const signature = files.find((f) => f.fieldname === 'signature');
    const photos = files.filter((f) => f.fieldname === 'photos');

    const signatureFileId = signature
      ? (
          await this.filesService.upload({
            resourceId: id,
            resource: 'last-mile',
            code: 'pod-signature',
            file: signature,
          })
        ).id
      : null;
    const photoFileIds = photos.length
      ? (await this.filesService.uploadMany(id, 'last-mile', photos)).map((r) => r.id)
      : [];

    await this.lastMileRepository.update(id, {
      podRecipientName: dto.recipientName.trim(),
      podSignatureFileId: signatureFileId,
      podPhotoFileIds: photoFileIds,
      podNotes: dto.notes?.trim() || null,
      podCapturedAt: new Date(),
    } as never);

    if (existing.status !== 'DELIVERED') {
      return this.update(id, { status: 'DELIVERED' } as UpdateLastMileDto);
    }
    return this.findById(id);
  }

  /**
   * The EDR last-mile trucks assigned to a booking, joined with driver details,
   * shaped for the arrival/exit weighing prefill (plate, driver, type, container).
   * Returns [] when the booking has no last-mile truck assigned. Lets the
   * warehouse arrival/load modals surface an assigned EDR truck the same way the
   * self-haul customer trucks are surfaced.
   */
  async arrivalTrucksForBooking(bookingId: string): Promise<
    Array<{
      vehicleId: string;
      truckPlateNumber: string | null;
      trailerPlateNumber: string | null;
      driverName: string | null;
      driverLicense: string | null;
      driverPhone: string | null;
      truckType: string | null;
      containerNumber: string | null;
      arrivedAt: string | null;
      departedAt: string | null;
    }>
  > {
    const [lm] = await this.lastMileRepository.findAll({
      where: { bookingId },
      relations: { vehicle: true, vehicleAssignments: { vehicle: true, containers: true } },
      take: 1,
    });
    if (!lm) return [];

    // Prefer the multi-truck junction; fall back to the legacy single vehicle.
    const sources = lm.vehicleAssignments?.length
      ? lm.vehicleAssignments.map((va) => ({
          vehicle: va.vehicle,
          containerNumber: va.containerNumber ?? null,
          arrivedAt: va.arrivedAt ?? null,
          departedAt: va.departedAt ?? null,
        }))
      : lm.vehicle
        ? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }]
        : [];

    const out: Array<{
      vehicleId: string;
      truckPlateNumber: string | null;
      trailerPlateNumber: string | null;
      driverName: string | null;
      driverLicense: string | null;
      driverPhone: string | null;
      truckType: string | null;
      containerNumber: string | null;
      arrivedAt: string | null;
      departedAt: string | null;
    }> = [];
    for (const { vehicle, containerNumber, arrivedAt, departedAt } of sources) {
      if (!vehicle) continue;
      let driverName = vehicle.assignedDriverName ?? null;
      let driverLicense: string | null = null;
      let driverPhone: string | null = null;
      if (vehicle.assignedDriverId) {
        try {
          const d = await this.driversService.findById(vehicle.assignedDriverId);
          driverName = driverName || `${d.firstName ?? ''} ${d.lastName ?? ''}`.trim() || null;
          driverLicense = d.licenseNumber ?? null;
          driverPhone = d.phoneNumber ?? null;
        } catch {
          /* driver lookup is best-effort — plate still prefills */
        }
      }
      out.push({
        vehicleId: vehicle.id,
        truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null,
        trailerPlateNumber: vehicle.trailerPlateNo || null,
        driverName,
        driverLicense,
        driverPhone,
        truckType: vehicle.vehicleType || null,
        containerNumber,
        arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
        departedAt: departedAt ? new Date(departedAt).toISOString() : null,
      });
    }
    return out;
  }

  async create(dto: CreateLastMileDto): Promise<LastMile> {
    // Idempotent: a booking gets exactly one last-mile record. Extra trucks live
    // inside that record (vehicleAssignments), never as additional rows — so if a
    // last-mile already exists for this booking, return it instead of inserting a
    // duplicate delivery row (which is what made the same booking appear twice in
    // the Assign-Mile list).
    const [existing] = await this.lastMileRepository.findAll({
      where: { bookingId: dto.bookingId },
      take: 1,
    });
    if (existing) {
      return existing;
    }

    await this.assertEdrHaulsThisBooking(dto.bookingId);

    const record = await this.lastMileRepository.create({
      bookingId: dto.bookingId,
      status: dto.status ?? 'READY_TO_TRANSIT',
      advancedPayment: dto.advancedPayment ?? 0,
      remainingPayment: dto.remainingPayment ?? 0,
      estimatedKm: dto.estimatedKm ?? null,
      exactKm: dto.exactKm ?? null,
      vehicleId: dto.vehicleId ?? null,
      paid: (dto as any).paid ?? false,
    });

    if (dto.vehicleId) {
      await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
      const info = await this.vehicleInfo(dto.vehicleId);
      await this.history.record({
        eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
        vehicleId: dto.vehicleId,
        lastMileId: record.id,
        driverId: info.driverId,
        label: record.status,
        metadata: {
          mile: 'LAST',
          bookingRef: await this.resolveBookingRef(record),
          vehiclePlate: info.plate,
          driverName: info.driverName,
        },
      });
    }

    return record;
  }

  @OnEvent("last_mile.invoice.paid")
  async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
    try {
      // Invoice paid → the delivery is complete. Route through update() so it
      // also frees the trucks + records history (same as "Mark Delivered").
      await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto);
      this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`);
    } catch (err) {
      this.logger.error(
        `Failed to deliver last-mile ${payload.sourceId} on payment: ${String(err)}`,
      );
    }
  }

  async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
    const existing = await this.findById(id);

    // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle
    // assigned in this same request).
    if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {
      const vehicleId =
        dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId;
      if (!(await this.hasAssignedVehicle(id, vehicleId))) {
        throw new BadRequestException(
          'Assign a vehicle before marking this last-mile leg in transit',
        );
      }
    }

    // A last-mile truck must have a driver before it can be assigned (same rule
    // as setVehicles) — block driverless single-vehicle (re)assignment too.
    if (dto.vehicleId && dto.vehicleId !== existing.vehicleId) {
      const vehicle = await this.vehiclesService.findById(dto.vehicleId);
      if (!vehicle?.assignedDriverId) {
        throw new BadRequestException(
          `Truck ${vehicle?.plateNumber ?? dto.vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`,
        );
      }
    }

    const dtoAny = dto as any;
    const updated = await this.lastMileRepository.update(id, {
      ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
      ...(dto.status !== undefined ? { status: dto.status } : {}),
      ...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}),
      ...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}),
      ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
      ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
      ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
      ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
      // Truck-detention clock: stamp arrival when the vehicle goes IN_TRANSIT and
      // delivery when it reaches DELIVERED (first time only). Explicit dto values
      // below override the auto-stamp so staff can record the real times.
      ...(dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT' && !existing.arrivedAt
        ? { arrivedAt: new Date() }
        : {}),
      ...(dto.status === 'DELIVERED' && existing.status !== 'DELIVERED' && !existing.deliveredAt
        ? { deliveredAt: new Date() }
        : {}),
      ...(dtoAny.arrivedAt !== undefined ? { arrivedAt: dtoAny.arrivedAt ? new Date(dtoAny.arrivedAt) : null } : {}),
      ...(dtoAny.deliveredAt !== undefined ? { deliveredAt: dtoAny.deliveredAt ? new Date(dtoAny.deliveredAt) : null } : {}),
    } as any);

    if (!updated) {
      throw new NotFoundException(`Last-mile record ${id} not found`);
    }

    // Notify assigned driver on every explicit vehicle assignment or reassignment
    if (dto.vehicleId) {
      void this.notifyDriverAssignment(dto.vehicleId, existing);
    }

    // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines.
    const bookingRef = await this.resolveBookingRef(existing);
    if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) {
      // Keep vehicle availability in sync: new vehicle goes BUSY, replaced one
      // is freed if no other active trip still holds it.
      if (dto.vehicleId) {
        await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
      }
      if (existing.vehicleId) {
        await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
      }
      if (existing.vehicleId) {
        const info = await this.vehicleInfo(existing.vehicleId);
        await this.history.record({
          eventType: FleetEventType.MILE_VEHICLE_RELEASED,
          vehicleId: existing.vehicleId,
          lastMileId: id,
          driverId: info.driverId,
          metadata: {
            mile: 'LAST',
            bookingRef,
            vehiclePlate: info.plate,
            driverName: info.driverName,
          },
        });
      }
      if (dto.vehicleId) {
        const info = await this.vehicleInfo(dto.vehicleId);
        await this.history.record({
          eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
          vehicleId: dto.vehicleId,
          lastMileId: id,
          driverId: info.driverId,
          label: updated.status,
          metadata: {
            mile: 'LAST',
            bookingRef,
            vehiclePlate: info.plate,
            driverName: info.driverName,
          },
        });
      }
    }

    if (dto.status !== undefined && dto.status !== existing.status) {
      const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
      const info = await this.vehicleInfo(vehicleId);
      await this.history.record({
        eventType: FleetEventType.MILE_STATUS_CHANGED,
        lastMileId: id,
        vehicleId,
        driverId: info.driverId,
        fromValue: existing.status,
        toValue: dto.status,
        metadata: {
          mile: 'LAST',
          bookingRef,
          vehiclePlate: info.plate,
          driverName: info.driverName,
        },
      });
    }

    // Delivery finished — free the vehicles this trip was holding.
    if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') {
      await this.releaseVehicles(updated);
    }

    return updated;
  }

  /**
   * Free every vehicle held by this record — junction assignments, the legacy
   * direct vehicle, and container allocations — unless still used by another
   * active trip.
   */
  private async releaseVehicles(record: LastMile): Promise<void> {
    const [assignments, recordAllocations] = await Promise.all([
      this.dataSource.manager.find(LastMileVehicleAssignment, {
        where: { lastMileId: record.id },
      }),
      this.dataSource.manager.find(LastMileContainerAllocation, {
        where: { lastMileId: record.id },
      }),
    ]);
    const vehicleIds = [
      ...new Set(
        [
          ...assignments.map((a) => a.vehicleId),
          ...recordAllocations.map((a) => a.vehicleId),
          record.vehicleId ?? null,
        ].filter((id): id is string => Boolean(id)),
      ),
    ];
    await this.vehiclesService.releaseIfUnused(vehicleIds);
  }

  /**
   * Replace the full set of vehicles serving a last-mile delivery (multi-truck).
   * Diffs against the current junction rows, syncing availability + audit history
   * for each added/removed vehicle. The first vehicle is mirrored onto the legacy
   * `vehicleId` column for back-compat with single-vehicle readers.
   */
  /** Container numbers on the booking (upper-cased). */
  private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
    const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
      `SELECT bcu.container_number AS "containerNumber"
         FROM freight.booking_container_units bcu
         JOIN freight.booking_container bc
           ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
        WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
      [bookingId],
    );
    return rows.map((r) => r.containerNumber.trim().toUpperCase());
  }

  /** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */

  /**
   * Bulk drawdown: how much of the booking's tonnage is still to be hauled —
   * the booking VGM total minus the net weighed off every EDR truck that has
   * already left. Both sides are tonnes, so no conversion.
   */
  async remainingTonsForBooking(bookingId: string): Promise<{
    totalTons: number;
    hauledTons: number;
    remainingTons: number;
    complete: boolean;
  }> {
    // Counts customer trucks as well as EDR ones — a booking hauls by one path
    // or the other, and "until no tonnage is left" means the same either way.
    return remainingBulkTons(this.dataSource, bookingId);
  }

  /**
   * Truck capacity rules for a last-mile delivery.
   *  - CONTAINER: a truck carries ONE 40ft or up to TWO 20ft; every container
   *    must belong to the booking and ride exactly one truck; never more trucks
   *    than containers.
   *  - BULK: no containers — trucks haul loose tonnage, so the only limit is
   *    that there is tonnage left to haul.
   */
  private async assertVehicleLoads(
    bookingId: string,
    desired: string[],
    loads: Map<string, string[]>,
  ): Promise<void> {
    if (!desired.length) return;

    const [booking]: Array<{ freightType: string | null }> = await this.dataSource.query(
      `SELECT freight_type AS "freightType"
         FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
      [bookingId],
    );
    if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
      const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
      assertBulkTonnageRemains(totalTons, remainingTons);
      return;
    }

    const bookingNumbers = await this.bookingContainerNumbers(bookingId);
    if (!bookingNumbers.length) return; // nothing to validate against

    const seen = new Set<string>();
    for (const vehicleId of desired) {
      const load = loads.get(vehicleId) ?? [];
      assertTruckLoad({
        containers: load,
        bookingContainers: bookingNumbers,
        sizes: await bookingContainerSizes(this.dataSource, bookingId, load),
        assignedElsewhere: [...seen],
      });
      load.forEach((n) => seen.add(n));
    }

    assertTruckCountWithinContainers(desired.length, bookingNumbers.length);
  }

  /**
   * A truck that has already reached the customer cannot have its load rewritten
   * — the containers on it are a delivered fact, not a plan. The customer side
   * has locked this since it was built (`Cannot edit a truck that has already
   * arrived`); the EDR side let a reassignment silently rewrite history.
   */
  private async assertNoArrivedVehicleChanged(
    current: LastMileVehicleAssignment[],
    desiredMap: Map<string, string[]>,
  ): Promise<void> {
    const loadKey = (list: string[]) => [...list].sort().join('|');
    for (const assignment of current) {
      if (!assignment.arrivedAt) continue;
      const stillPresent = desiredMap.has(assignment.vehicleId);
      const load = desiredMap.get(assignment.vehicleId) ?? [];
      const currentLoad = (assignment.containers ?? []).map((c) =>
        c.containerNumber.trim().toUpperCase(),
      );
      if (!stillPresent || loadKey(load) !== loadKey(currentLoad)) {
        throw new ConflictException(
          'This truck has already arrived — its load can no longer be changed or removed',
        );
      }
    }
  }

  async setVehicles(
    id: string,
    inputs: Array<{
      vehicleId: string;
      containerNumbers?: string[] | null;
      containerNumber?: string | null;
    }>,
  ): Promise<LastMile> {
    const existing = await this.findById(id);
    // Dedupe by vehicleId, keeping the container load; preserve order. Accepts
    // the legacy single `containerNumber` as a one-element load.
    const desiredMap = new Map<string, string[]>();
    for (const inp of inputs) {
      if (!inp.vehicleId) continue;
      const load = (inp.containerNumbers ?? (inp.containerNumber ? [inp.containerNumber] : []))
        .map((n) => String(n).trim().toUpperCase())
        .filter(Boolean);
      desiredMap.set(inp.vehicleId, load);
    }
    const desired = [...desiredMap.keys()];
    const desiredSet = new Set(desired);

    // Capacity + membership rules (a truck holds one 40ft or two 20ft; bulk
    // hauls tonnage until the booking is drawn down).
    await this.assertVehicleLoads(existing.bookingId, desired, desiredMap);

    const manager = this.dataSource.manager;
    const current = await manager.find(LastMileVehicleAssignment, {
      where: { lastMileId: id },
      relations: { containers: true },
    });
    await this.assertNoArrivedVehicleChanged(current, desiredMap);

    const junctionSet = new Set(current.map((a) => a.vehicleId));
    // Fold the legacy vehicleId into the release set — a vehicle assigned via the
    // old single-vehicle path has no junction row but must still be freed.
    const releaseIds = [...new Set(
      current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []),
    )];
    const added = desired.filter((v) => !junctionSet.has(v));
    const removed = releaseIds.filter((v) => !desiredSet.has(v));

    // A last-mile truck must have a driver before it can be assigned — a delivery
    // can't run driverless, and the arrival/exit weighing needs the driver.
    for (const vehicleId of added) {
      const vehicle = await this.vehiclesService.findById(vehicleId);
      if (!vehicle?.assignedDriverId) {
        throw new BadRequestException(
          `Truck ${vehicle?.plateNumber ?? vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`,
        );
      }
    }
    // Vehicles that stay but whose container load changed (order-insensitive).
    const loadKey = (list: string[]) => [...list].sort().join('|');
    const currentLoad = (a: LastMileVehicleAssignment) =>
      (a.containers ?? []).map((c) => c.containerNumber.trim().toUpperCase());
    const changed = current.filter(
      (a) =>
        desiredMap.has(a.vehicleId) &&
        loadKey(desiredMap.get(a.vehicleId) ?? []) !== loadKey(currentLoad(a)),
    );

    await this.dataSource.transaction(async (tx) => {
      if (removed.length) {
        // Child containers cascade on delete.
        await tx.delete(LastMileVehicleAssignment, {
          lastMileId: id,
          vehicleId: In(removed),
        });
      }
      for (const vehicleId of added) {
        const load = desiredMap.get(vehicleId) ?? [];
        const inserted = await tx.insert(LastMileVehicleAssignment, {
          lastMileId: id,
          vehicleId,
          // Legacy scalar stays in sync with the first container.
          containerNumber: load[0] ?? null,
        });
        const assignmentId = inserted.identifiers[0]?.id as string | undefined;
        if (assignmentId && load.length) {
          await tx.insert(
            LastMileVehicleContainer,
            load.map((containerNumber) => ({ assignmentId, lastMileId: id, containerNumber })),
          );
        }
      }
      for (const row of changed) {
        const load = desiredMap.get(row.vehicleId) ?? [];
        await tx.update(
          LastMileVehicleAssignment,
          { lastMileId: id, vehicleId: row.vehicleId },
          { containerNumber: load[0] ?? null },
        );
        await tx.delete(LastMileVehicleContainer, { assignmentId: row.id });
        if (load.length) {
          await tx.insert(
            LastMileVehicleContainer,
            load.map((containerNumber) => ({
              assignmentId: row.id,
              lastMileId: id,
              containerNumber,
            })),
          );
        }
      }
    });

    // Legacy primary vehicle = first of the set (null when cleared).
    await this.lastMileRepository.update(id, { vehicleId: desired[0] ?? null } as any);

    const bookingRef = await this.resolveBookingRef(existing);
    for (const vehicleId of added) {
      await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY);
      void this.notifyDriverAssignment(vehicleId, existing);
      const info = await this.vehicleInfo(vehicleId);
      await this.history.record({
        eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
        vehicleId,
        lastMileId: id,
        driverId: info.driverId,
        label: existing.status,
        metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
      });
    }
    for (const vehicleId of removed) {
      await this.vehiclesService.releaseIfUnused([vehicleId]);
      const info = await this.vehicleInfo(vehicleId);
      await this.history.record({
        eventType: FleetEventType.MILE_VEHICLE_RELEASED,
        vehicleId,
        lastMileId: id,
        driverId: info.driverId,
        metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
      });
    }

    return this.findById(id);
  }

  /**
   * Record each truck's actual distance. The delivery total (exact_km) is their
   * sum and drives billing; `remainingPayment` (total km × rate) is recomputed
   * client-side. Does NOT generate an invoice — that's a separate explicit step.
   */
  async setDistances(
    id: string,
    distances: Array<{ vehicleId: string; distanceKm: number }>,
    remainingPayment?: number,
  ): Promise<LastMile> {
    await this.findById(id);

    // Distances are locked once the invoice exists.
    const invoices = await this.billing.findBySourceIds('last_mile', [id]);
    if (invoices.length) {
      throw new BadRequestException(
        'Distances cannot be changed after the invoice is generated',
      );
    }

    for (const d of distances) {
      await this.dataSource.manager.update(
        LastMileVehicleAssignment,
        { lastMileId: id, vehicleId: d.vehicleId },
        { distanceKm: d.distanceKm },
      );
    }

    // Billing is per truck: amount = Σ (truck distance × truck price/km). The
    // per-vehicle rate + currency live on the vehicle, so we ignore the legacy
    // LAST_MILE flat rate and any client-sent amount. `remainingPayment` param
    // kept only for signature back-compat.
    void remainingPayment;
    const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, {
      where: { lastMileId: id },
      relations: { vehicle: true },
    });
    const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0);
    const amount = assignments.reduce(
      (s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
      0,
    );
    await this.lastMileRepository.update(id, {
      exactKm: total,
      remainingPayment: amount,
    } as any);
    return this.findById(id);
  }

  private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
    try {
      const vehicle = await this.vehiclesService.findById(vehicleId);
      if (!vehicle.assignedDriverId) {
        this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
        return;
      }

      const driver = await this.driversService.findById(vehicle.assignedDriverId);
      if (!driver.phoneNumber) {
        this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
        return;
      }

      type BookingWithYards = {
        reference?: string;
        lastMileDeliveryAddress?: string | null;
        destinationYard?: { label?: string } | null;
      };
      const booking = (record as LastMile & { booking?: BookingWithYards }).booking;

      const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
      const message =
        `Dear ${driverName}, you have been assigned to a last-mile delivery. ` +
        `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
        (booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') +
        (booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : '');

      void this.smsClient.sendSms({
        to: driver.phoneNumber,
        message,
      });

      this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
    } catch (err) {
      this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
    }
  }

  async remove(id: string): Promise<void> {
    const existing = await this.findById(id);

    // Can't delete once billed.
    const invoices = await this.billing.findBySourceIds('last_mile', [id]);
    if (invoices.length) {
      throw new BadRequestException(
        'Cannot delete a last-mile delivery after its invoice is generated',
      );
    }

    // Every vehicle this delivery holds — junction + legacy + container rows.
    const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, {
      where: { lastMileId: id },
    });
    const allocations = await this.dataSource.manager.find(LastMileContainerAllocation, {
      where: { lastMileId: id },
    });
    const vehicleIds = [
      ...new Set(
        [
          ...assignments.map((a) => a.vehicleId),
          ...allocations.map((a) => a.vehicleId),
          existing.vehicleId ?? null,
        ].filter((v): v is string => Boolean(v)),
      ),
    ];

    await this.lastMileRepository.softDelete(id);
    if (assignments.length) {
      await this.dataSource.manager.softDelete(LastMileVehicleAssignment, { lastMileId: id });
    }

    // Free every vehicle no longer held by another active trip (releaseIfUnused
    // ignores this now soft-deleted record) and audit the release.
    if (vehicleIds.length) {
      await this.vehiclesService.releaseIfUnused(vehicleIds);
      const bookingRef = await this.resolveBookingRef(existing);
      for (const vehicleId of vehicleIds) {
        const info = await this.vehicleInfo(vehicleId);
        await this.history.record({
          eventType: FleetEventType.MILE_VEHICLE_RELEASED,
          vehicleId,
          lastMileId: id,
          driverId: info.driverId,
          metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
        });
      }
    }
  }

}
