import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';

import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import {
  EDR_HAULAGE_CONFLICT_MESSAGE,
  usesEdrMileService,
} from '../../common/mile-haulage.util';
import {
  assertBulkTonnageRemains,
  assertTruckCountWithinContainers,
  assertTruckLoad,
  bookingContainerSizes,
  remainingBulkTons,
} from '../../common/truck-load.util';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';

interface BookingGuardRow {
  tradeDirection: string | null;
  freightType: string | null;
  firstMile: string | null;
  lastMile: string | null;
  paymentStatus: string | null;
  status: string | null;
}

/**
 * Multi-truck self-haul assignment. A booking with no EDR first/last-mile leg
 * can have several customer trucks, each carrying 1–2 of its containers and
 * tracking its own arrival. The legacy booking.customer_truck_* columns are kept
 * as a booking-level flag (any truck assigned / all arrived) so the warehouse
 * exit-gate + delivery-approval logic keep working unchanged.
 */
@Injectable()
export class CustomerTruckService {
  private readonly logger = new Logger(CustomerTruckService.name);

  constructor(
    private readonly dataSource: DataSource,
    private readonly assignments: CustomerTruckAssignmentsRepository,
    private readonly inbox: NotificationInboxService,
    private readonly notifications: NotificationsService,
  ) {}

  listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
    return this.assignments.findByBookingId(bookingId);
  }

  async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
    const booking = await this.loadBookingGuard(bookingId);
    this.assertSelfHaulPaid(booking);
    this.assertAssignmentWindow(booking);

    // Bulk bookings have no containers — the truck hauls loose tonnage and is
    // weighed out on departure (gross_weight_kg). Container bookings assign the
    // 1–2 specific containers each truck carries.
    const isBulk = booking.freightType === 'BULK';
    const requested = isBulk
      ? []
      : (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());

    // Container capacity is size-based: a 40ft container fills the truck (max 1);
    // two 20ft containers fit (max 2), no size mixing. #trucks <= #containers
    // follows naturally since each container is assigned to exactly one truck.
    if (!isBulk && requested.length < 1) {
      throw new BadRequestException('Select at least one container for this truck');
    }

    // Bulk is capped by tonnage, not container count: trucks may be added until
    // the booking's declared weight has been hauled away. Container bookings are
    // capped below by #trucks <= #containers.
    if (isBulk) {
      const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
      assertBulkTonnageRemains(totalTons, remainingTons);

      // Assignment-time drawdown: planned tonnage across live trucks (weighed
      // net once departed, planned before) may not exceed the declared total.
      if (totalTons > 0) {
        const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
          `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
             FROM freight.customer_truck_assignments a
            WHERE a.booking_id = $1 AND a.deleted_at IS NULL`,
          [bookingId],
        );
        const alreadyPlanned = Number(p?.planned ?? 0);
        const requestedTons = Number(dto.plannedTons ?? 0);
        if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) {
          throw new BadRequestException(
            `Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`,
          );
        }
      }
    }

    if (requested.length) {
      const bookingNumbers = await this.bookingContainerNumbers(bookingId);
      const existingTrucks = await this.dataSource
        .getRepository(CustomerTruckAssignment)
        .count({ where: { bookingId } });
      assertTruckCountWithinContainers(existingTrucks + 1, bookingNumbers.length);
      assertTruckLoad({
        containers: requested,
        bookingContainers: bookingNumbers,
        sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
        assignedElsewhere: await this.assignedContainerNumbers(bookingId),
      });
    }

    await this.dataSource.transaction(async (manager) => {
      const assignment = await manager.getRepository(CustomerTruckAssignment).save(
        manager.getRepository(CustomerTruckAssignment).create({
          bookingId,
          plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
          driverName: dto.driverName.trim(),
          truckType: dto.truckType.trim(),
          plannedTons: isBulk ? (dto.plannedTons ?? null) : null,
          plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null,
        }),
      );
      await manager.getRepository(CustomerTruckContainer).save(
        requested.map((containerNumber) =>
          manager.getRepository(CustomerTruckContainer).create({
            assignmentId: assignment.id,
            bookingId,
            containerNumber,
          }),
        ),
      );
      // Booking-level flag: first truck marks the booking as truck-assigned.
      await manager.query(
        `UPDATE freight.bookings
            SET customer_truck_assigned_at = COALESCE(customer_truck_assigned_at, NOW()),
                status = CASE WHEN status = 'PAID' THEN 'TRUCK_ASSIGNED' ELSE status END,
                updated_at = NOW()
          WHERE id = $1`,
        [bookingId],
      );
    });

    return this.listTrucks(bookingId);
  }

  async removeTruck(bookingId: string, assignmentId: string): Promise<CustomerTruckAssignment[]> {
    const assignment = await this.assignments.findByIdWithContainers(assignmentId);
    if (!assignment || assignment.bookingId !== bookingId) {
      throw new NotFoundException('Truck assignment not found for this booking');
    }
    if (assignment.arrivedAt) {
      throw new ConflictException('Cannot remove a truck that has already arrived');
    }

    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
      await manager.getRepository(CustomerTruckAssignment).softDelete(assignmentId);
      const remaining = await manager
        .getRepository(CustomerTruckAssignment)
        .count({ where: { bookingId } });
      if (remaining === 0) {
        // No trucks left — clear the booking-level flag and revert the status.
        await manager.query(
          `UPDATE freight.bookings
              SET customer_truck_assigned_at = NULL,
                  status = CASE WHEN status = 'TRUCK_ASSIGNED' THEN 'PAID' ELSE status END,
                  updated_at = NOW()
            WHERE id = $1`,
          [bookingId],
        );
      }
    });

    return this.listTrucks(bookingId);
  }

  /**
   * Edit a truck assignment — plate/driver/type and the containers it carries.
   * Allowed only until the truck has arrived (same guard as removal). Container
   * rules mirror {@link addTruck}: 1–2 of the booking's containers, none already
   * on another truck, and a 40ft container fills the truck (max 1).
   */
  async updateTruck(
    bookingId: string,
    assignmentId: string,
    dto: AddCustomerTruckDto,
  ): Promise<CustomerTruckAssignment[]> {
    const booking = await this.loadBookingGuard(bookingId);
    this.assertSelfHaulPaid(booking);

    const assignment = await this.assignments.findByIdWithContainers(assignmentId);
    if (!assignment || assignment.bookingId !== bookingId) {
      throw new NotFoundException('Truck assignment not found for this booking');
    }
    if (assignment.arrivedAt) {
      throw new ConflictException('Cannot edit a truck that has already arrived');
    }

    // Bulk trucks carry loose tonnage, not containers — planned tonnage is
    // editable instead, capped by what the other trucks haven't claimed.
    const isBulk = booking.freightType === 'BULK';
    const requested = isBulk
      ? []
      : (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
    if (!isBulk && requested.length < 1) {
      throw new BadRequestException('Select at least one container for this truck');
    }
    if (!isBulk) {
      assertTruckLoad({
        containers: requested,
        bookingContainers: await this.bookingContainerNumbers(bookingId),
        sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
        // Exclude THIS truck's own containers so re-saving the same set is allowed.
        assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
      });
    } else if (dto.plannedTons != null) {
      const { totalTons } = await remainingBulkTons(this.dataSource, bookingId);
      if (totalTons > 0) {
        const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
          `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
             FROM freight.customer_truck_assignments a
            WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`,
          [bookingId, assignmentId],
        );
        const others = Number(p?.planned ?? 0);
        if (others + Number(dto.plannedTons) > totalTons + 0.001) {
          throw new BadRequestException(
            `Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`,
          );
        }
      }
    }

    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
        plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
        driverName: dto.driverName.trim(),
        truckType: dto.truckType.trim(),
        ...(isBulk
          ? {
              plannedTons: dto.plannedTons ?? null,
              plannedQuantity: dto.plannedQuantity ?? null,
            }
          : {}),
      });
      await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
      await manager.getRepository(CustomerTruckContainer).save(
        requested.map((containerNumber) =>
          manager.getRepository(CustomerTruckContainer).create({
            assignmentId,
            bookingId,
            containerNumber,
          }),
        ),
      );
    });

    return this.listTrucks(bookingId);
  }

  /**
   * Register an IMPORT self-haul truck leaving the port: the containers it
   * actually loaded (replacing any provisional list) and its weighed gross.
   * Export bookings have no truck departure — trucks only deliver (receive).
   */
  async departTruck(
    bookingId: string,
    assignmentId: string,
    dto: DepartCustomerTruckDto,
  ): Promise<CustomerTruckAssignment[]> {
    const booking = await this.loadBookingGuard(bookingId);
    if (booking.tradeDirection !== 'IMPORT') {
      throw new BadRequestException(
        'Truck departure/weighing applies to import self-haul only (export trucks only deliver)',
      );
    }
    const assignment = await this.assignments.findByIdWithContainers(assignmentId);
    if (!assignment || assignment.bookingId !== bookingId) {
      throw new NotFoundException('Truck assignment not found for this booking');
    }
    // Once filled, the departure record is uneditable.
    if (assignment.departedAt) {
      throw new ConflictException('This truck has already departed — its exit record is locked');
    }

    const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
    if (requested.length) {
      const bookingNumbers = await this.bookingContainerNumbers(bookingId);
      for (const n of requested) {
        if (!bookingNumbers.includes(n)) {
          throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
        }
      }
      const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
      for (const n of requested) {
        if (elsewhere.includes(n)) {
          throw new ConflictException(`Container ${n} is already loaded onto another truck`);
        }
      }
    }

    await this.dataSource.transaction(async (manager) => {
      if (requested.length) {
        // Replace the truck's containers with what was actually loaded.
        await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
        await manager.getRepository(CustomerTruckContainer).save(
          requested.map((containerNumber) =>
            manager.getRepository(CustomerTruckContainer).create({
              assignmentId,
              bookingId,
              containerNumber,
            }),
          ),
        );
      }
      await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
        grossWeightKg: dto.grossWeightKg,
        departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(),
        arrivedAt: assignment.arrivedAt ?? new Date(),
      });
    });

    return this.listTrucks(bookingId);
  }

  /** Booking container numbers not yet loaded onto any truck. */
  async getLoadableContainers(bookingId: string): Promise<string[]> {
    const [all, assigned] = await Promise.all([
      this.bookingContainerNumbers(bookingId),
      this.assignedContainerNumbers(bookingId),
    ]);
    const taken = new Set(assigned);
    return all.filter((n) => !taken.has(n));
  }

  /**
   * Truck_dispatch (load): assign the selected containers to a truck after it has
   * arrived, and set a provisional gross weight from their VGM. The truck is
   * weighed for real on departure. Locked once the truck has left.
   */
  async loadTruck(
    bookingId: string,
    assignmentId: string,
    dto: { containerNumbers: string[] },
  ): Promise<CustomerTruckAssignment[]> {
    await this.loadBookingGuard(bookingId);
    const assignment = await this.assignments.findByIdWithContainers(assignmentId);
    if (!assignment || assignment.bookingId !== bookingId) {
      throw new NotFoundException('Truck assignment not found for this booking');
    }
    if (assignment.departedAt) {
      throw new ConflictException('This truck has already left — its load is locked');
    }
    // Loading a truck at the warehouse implies it is physically present, so a
    // truck that is still only assigned (not yet marked arrived) is auto-arrived
    // here rather than blocking the operator — the real gross is weighed on
    // departure anyway.
    const needsArrival = !assignment.arrivedAt;

    const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
    if (!requested.length) {
      throw new BadRequestException('Select at least one container to load onto the truck');
    }
    // Capacity is size-based: a truck carries at most 2 containers, and a 40ft
    // container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
    assertTruckLoad({
      containers: requested,
      bookingContainers: await this.bookingContainerNumbers(bookingId),
      sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
      assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
    });

    const grossTons = await this.vgmTonsForContainers(bookingId, requested);
    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
      // Operator loading the truck: stamp loaded_at so these containers move to
      // the LOADED stage (customer assignment alone leaves loaded_at null).
      const loadedAt = new Date();
      await manager.getRepository(CustomerTruckContainer).save(
        requested.map((containerNumber) =>
          manager.getRepository(CustomerTruckContainer).create({
            assignmentId,
            bookingId,
            containerNumber,
            loadedAt,
          }),
        ),
      );
      // Provisional gross (tonnes) from the loaded containers' VGM — overridden
      // by the weighed gross on departure. (Column is *_kg but holds tonnes.)
      // Auto-stamp arrival if the truck was still only assigned.
      await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
        grossWeightKg: grossTons,
        ...(needsArrival ? { arrivedAt: new Date() } : {}),
      });
      if (needsArrival) {
        await manager.query(
          `UPDATE freight.bookings
              SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
                  updated_at = NOW()
            WHERE id = $1`,
          [bookingId],
        );
      }
    });
    return this.listTrucks(bookingId);
  }

  /** Summed VGM (tonnes) of the given containers — provisional truck gross. */
  private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise<number> {
    const [row]: Array<{ tons: string }> = await this.dataSource.query(
      `SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons
         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.container_number = ANY($2::varchar[])
          AND bcu.deleted_at IS NULL`,
      [bookingId, numbers],
    );
    return Number(row?.tons ?? 0);
  }

  /**
   * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
   * receive flow. When every truck on the booking has arrived, the booking-level
   * customer_truck_arrived_at flag is stamped (used by the delivery-approval
   * gate). No-op when the container is not on any customer truck.
   */
  async markArrivedByContainer(
    bookingId: string,
    containerNumber: string,
    manager?: EntityManager,
  ): Promise<void> {
    const m = manager ?? this.dataSource.manager;
    const cn = containerNumber.trim().toUpperCase();
    const container = await m.getRepository(CustomerTruckContainer).findOne({
      where: { bookingId, containerNumber: cn },
    });
    if (!container) return;

    const assignment = await m
      .getRepository(CustomerTruckAssignment)
      .findOne({ where: { id: container.assignmentId } });
    const justArrived = Boolean(assignment) && !assignment?.arrivedAt;

    await m
      .getRepository(CustomerTruckAssignment)
      .update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });

    await this.syncBookingArrival(bookingId, m);

    if (justArrived && assignment) {
      await this.notifyTruckArrival(bookingId, assignment.plateNumber, m);
    }
  }

  /** Mark every truck on the booking arrived (fallback when no container is known). */
  async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
    const m = manager ?? this.dataSource.manager;
    const justArrived = await m
      .getRepository(CustomerTruckAssignment)
      .find({ where: { bookingId, arrivedAt: IsNull() } });
    await m
      .getRepository(CustomerTruckAssignment)
      .update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
    await this.syncBookingArrival(bookingId, m);
    for (const truck of justArrived) {
      await this.notifyTruckArrival(bookingId, truck.plateNumber, m);
    }
  }

  /**
   * Best-effort truck-arrival notification to the booking's company across every
   * channel: in-app (portal inbox) + SMS + email. Never throws — a missing
   * provider or contact must not break the arrival flow.
   */
  private async notifyTruckArrival(
    bookingId: string,
    plateNumber: string | null,
    m: EntityManager,
  ): Promise<void> {
    try {
      const [booking]: Array<{ companyId: string | null; reference: string | null }> =
        await m.query(
          `SELECT company_id AS "companyId", reference
             FROM freight.bookings
            WHERE id = $1 AND deleted_at IS NULL`,
          [bookingId],
        );
      if (!booking?.companyId) return;
      const ref = booking.reference ?? bookingId;
      const truck = plateNumber ? `Truck ${plateNumber}` : 'A customer truck';
      const body = `${truck} has arrived at the terminal for booking ${ref}.`;
      await this.inbox.notify({
        recipients: { companyId: booking.companyId },
        audience: NotificationAudience.PORTAL,
        type: NotificationType.BOOKING_STATUS,
        title: 'Truck arrived',
        body,
        link: `/bookings/${bookingId}`,
        data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
      });
      await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
    } catch (err) {
      this.logger.warn(
        `Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`,
      );
    }
  }

  /**
   * Stamp the booking-level arrival flag on the FIRST truck arrival. The import
   * handover is signed once, before the first truck leaves, even though trucks
   * pick up per-container — so the flag fires on the first arrival (COALESCE
   * keeps it), not once all trucks have arrived.
   */
  private async syncBookingArrival(bookingId: string, m: EntityManager): Promise<void> {
    await m.query(
      `UPDATE freight.bookings
          SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
              updated_at = NOW()
        WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL`,
      [bookingId],
    );
  }

  private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
    const [row]: BookingGuardRow[] = await this.dataSource.query(
      `SELECT trade_direction AS "tradeDirection",
              freight_type AS "freightType",
              first_mile_pickup_address AS "firstMile",
              last_mile_delivery_address AS "lastMile",
              payment_status AS "paymentStatus",
              status
         FROM freight.bookings
        WHERE id = $1 AND deleted_at IS NULL`,
      [bookingId],
    );
    if (!row) throw new NotFoundException(`Booking ${bookingId} not found`);
    return row;
  }

  private assertSelfHaulPaid(booking: BookingGuardRow): void {
    // Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two
    // halves of this rule cannot drift apart — they did, and a booking ended up
    // with a customer truck and an EDR leg at once.
    if (usesEdrMileService(booking)) {
      throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
    }
    if (booking.paymentStatus !== 'PAID') {
      throw new BadRequestException(
        'Booking must be paid before assigning an external customer truck',
      );
    }
  }

  /**
   * Assignment window by direction:
   *  - IMPORT: pickup trucks are assigned only AFTER the train has arrived.
   *  - EXPORT / DOMESTIC: delivery trucks are assigned only BEFORE the cargo is
   *    loaded onto the train (booking still PAID / TRUCK_ASSIGNED). Once loaded
   *    (IN_TRANSIT and beyond) assignment is closed.
   */
  private assertAssignmentWindow(booking: BookingGuardRow): void {
    const status = booking.status ?? '';
    if (booking.tradeDirection === 'IMPORT') {
      if (status !== 'ARRIVED') {
        throw new BadRequestException(
          'Import pickup trucks can only be assigned after the train has arrived',
        );
      }
      return;
    }
    if (!['PAID', 'TRUCK_ASSIGNED'].includes(status)) {
      throw new BadRequestException(
        'Export delivery trucks can only be assigned before the cargo is loaded onto the train',
      );
    }
  }

  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());
  }

  private async assignedContainerNumbers(bookingId: string): Promise<string[]> {
    const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
      `SELECT container_number AS "containerNumber"
         FROM freight.customer_truck_containers
        WHERE booking_id = $1 AND deleted_at IS NULL`,
      [bookingId],
    );
    return rows.map((r) => r.containerNumber.trim().toUpperCase());
  }

  private async assignedContainerNumbersExcept(
    bookingId: string,
    exceptAssignmentId: string,
  ): Promise<string[]> {
    const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
      `SELECT container_number AS "containerNumber"
         FROM freight.customer_truck_containers
        WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`,
      [bookingId, exceptAssignmentId],
    );
    return rows.map((r) => r.containerNumber.trim().toUpperCase());
  }

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

  async addBulkTrucks(
    bookingId: string,
    dtos: AddCustomerTruckDto[],
  ): Promise<{
    success: number;
    failed: number;
    errors: Array<{ row: number; truck: string; reason: string }>;
  }> {
    const errors: Array<{ row: number; truck: string; reason: string }> = [];
    let successCount = 0;

    for (let i = 0; i < dtos.length; i++) {
      try {
        await this.addTruck(bookingId, dtos[i]);
        successCount++;
      } catch (err: any) {
        errors.push({
          row: i + 2, // Row 1 is header
          truck: dtos[i].truckPlateNumber,
          reason: err.message || 'Unknown error',
        });
      }
    }

    return {
      success: successCount,
      failed: errors.length,
      errors,
    };
  }
}
