import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  forwardRef,
  Inject,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import {
  BookingEvaluationInput,
  RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, In } from 'typeorm';

import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { assertFreightShape } from './booking-freight.util';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import {
  BOOKING_STATUSES,
  CUSTOMER_EDITABLE_STATUSES,
  FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';

/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings {
  items: Booking[];
  total: number;
  meta: {
    page: number;
    pageSize: number;
    total: number;
    totalPages: number;
    hasNextPage: boolean;
    hasPreviousPage: boolean;
  };
}

const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
  'SUBMITTED',
  'PENDING_APPROVAL',
  'APPROVED_PENDING_SIGNATURE',
] as const;

@Injectable()
export class BookingsService {
  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
    private readonly bookingsRepository: BookingsRepository,
    private readonly filesService: FilesService,
    private readonly minioService: MinioService,
    // private readonly customersService: CustomersService,
    private readonly companiesService: CompaniesService,
    @Inject(forwardRef(() => TrainSchedulingService))
    private readonly trainSchedulingService: TrainSchedulingService,
    private readonly ruleEngineService: RuleEngineService,
    private readonly containerTypesService: ContainerTypesService,
    private readonly consolidationService: ConsolidationService,
  ) {}

  /** Resolve trade direction from yard countries; reject client mismatch. */
  private async resolveTradeDirectionForBooking(
    originYardId: string,
    destinationYardId: string,
    provided?: string,
  ): Promise<string> {
    const yards = await this.dataSource.getRepository(Yard).find({
      where: { id: In([originYardId, destinationYardId]) },
    });
    const origin = yards.find((y) => y.id === originYardId);
    const destination = yards.find((y) => y.id === destinationYardId);
    if (!origin) {
      throw new BadRequestException(`Origin yard ${originYardId} not found`);
    }
    if (!destination) {
      throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
    }
    if (originYardId === destinationYardId) {
      throw new BadRequestException('Origin and destination yards must differ');
    }

    const expected = deriveTradeDirection(origin, destination);
    if (provided && provided !== expected) {
      throw new BadRequestException(
        `tradeDirection must be ${expected} for the selected yard pair (got ${provided})`,
      );
    }
    return expected;
  }

  /** Generate a unique booking reference number. */
  private async generateReference(): Promise<string> {
    const year = new Date().getFullYear();
    const count = await this.bookingsRepository.countByYear(year);
    return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
  }

  /** Build evaluation input from booking freight shape. */
  private async buildEvalInput(dto: {
    freightType: FreightType;
    cargoTypeId?: string | null;
    serviceTypeId: string;
    paymentCurrency: string;
    tradeDirection: string;
    isHazardous?: boolean;
    isGovernment?: boolean;
    shippingLineId?: string | null;
    containers: CreateBookingContainerDto[];
  }): Promise<BookingEvaluationInput> {
    const containerLines =
      dto.freightType === 'CONTAINER' ? dto.containers : [];

    const containers = await Promise.all(
      containerLines.map(async (c) => {
        const ct = await this.containerTypesService.findById(c.containerTypeId);
        const totalVgmTons = c.quantity * c.vgmPerUnitTons;
        return {
          containerTypeId: c.containerTypeId,
          quantity: c.quantity,
          vgmPerUnitTons: c.vgmPerUnitTons,
          totalVgmTons,
          isReefer: ct.isReefer,
          wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
        };
      }),
    );
    const totalWagons = Math.ceil(
      containers.reduce((sum, c) => sum + c.wagonsRequired, 0),
    );

    // Consolidation is system-managed: the CONSOLIDATION_ENABLED rule trigger
    // fires whenever a container line leaves a wagon partially filled. There is
    // no customer opt-in — partial-wagon cargo always consolidates.
    const allowConsolidation =
      dto.freightType === 'CONTAINER'
        ? await this.needsConsolidation(dto.containers)
        : false;

    return {
      freightType: dto.freightType,
      cargoTypeId: dto.cargoTypeId ?? null,
      serviceTypeId: dto.serviceTypeId,
      paymentCurrency: dto.paymentCurrency,
      tradeDirection: dto.tradeDirection,
      isHazardous: dto.isHazardous ?? false,
      isGovernment: dto.isGovernment ?? false,
      allowConsolidation,
      shippingLineId: dto.shippingLineId,
      totalWagons,
      containers,
    };
  }

  /**
   * True when any container line leaves a wagon partially filled (e.g. 1×20ft on
   * a 2-slot wagon). Partial-wagon cargo must consolidate before it can finalize;
   * cargo that already fills whole wagons never does. This is computed from the
   * container quantities alone — there is no customer-facing opt-in flag.
   */
  private async needsConsolidation(
    containers: CreateBookingContainerDto[],
  ): Promise<boolean> {
    return this.consolidationService.needsConsolidation(
      containers.map((c) => ({
        containerTypeId: c.containerTypeId,
        quantity: c.quantity,
      })),
    );
  }

  /** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */
  private async tryAutoConsolidate(booking: Booking): Promise<{
    booking: Booking;
    messages: string[];
  }> {
    const messages: string[] = [];

    if (booking.consolidationPartnerId) {
      return { booking, messages };
    }

    // Only partial-wagon container lines produce slots; full-wagon (and bulk)
    // bookings return none and need no consolidation.
    const slots = await this.consolidationService.slotsFromBooking(booking);
    if (slots.length === 0) {
      return { booking, messages };
    }

    const partner = await this.bookingsRepository.findConsolidationPartner(
      booking,
      slots,
    );

    if (partner) {
      await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
      const paired = await this.findById(booking.id);
      messages.push(
        this.consolidationService.describePaired(partner.reference, slots),
      );
      return { booking: paired, messages };
    }

    // No partner yet — park the booking so it waits. Applies both pre-submit
    // (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
    // reach this method.
    if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
      await this.bookingsRepository.parkForConsolidation(booking.id);
    }

    const pending = await this.findById(booking.id);
    messages.push(this.consolidationService.describePending(pending, slots));
    return { booking: pending, messages };
  }

  /**
   * Run consolidation right after a booking reaches SUBMITTED. If a complementary
   * partner already exists, both are paired and moved (back) to SUBMITTED so staff
   * can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
   * waits for a later complementary booking to complete the wagon.
   *
   * Returns the re-fetched booking, so callers can reflect the resulting status
   * (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
   */
  async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
    const booking = await this.findById(bookingId);

    // Already paired (e.g. a partner submitted first) — nothing to do.
    if (booking.consolidationPartnerId) {
      return booking;
    }

    const result = await this.tryAutoConsolidate(booking);
    return result.booking;
  }

  /** Create a new freight booking. */
  async create(
    dto: CreateBookingDto,
    files: Express.Multer.File[],
    userId?: string,
  ): Promise<{ booking: Booking; warnings: string[] }> {
    const warnings: string[] = [];

    // let customerId = dto.customerId;
    // if (!customerId) {
    //   if (!userId) {
    //     throw new BadRequestException(
    //       'customerId is required or must be resolvable from auth token',
    //     );
    //   }
    //   const customer = await this.customersService.findByUserId(userId);
    //   customerId = customer.id;
    // }

    const isGovernment = dto.isGovernment === true;
    const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';

    let companyId: string | null | undefined = dto.companyId;
    if (isGovernment) {
      if (!dto.governmentInstitution?.trim()) {
        throw new BadRequestException('governmentInstitution is required for government bookings');
      }
      companyId = dto.companyId ?? null;
    } else if (!companyId) {
      if (!userId) {
        throw new BadRequestException(
          'companyId is required or must be resolvable from auth token',
        );
      }
      const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
      // A customer can only book once their company has been approved.
      if (company.status !== CompanyStatus.Active) {
        throw new ForbiddenException(
          "Your company is awaiting approval — you can't create bookings yet.",
        );
      }
      companyId = company.id;
    }

    if (dto.trainScheduleId) {
      // Staff manual pin: the schedule must be OPEN and on the same route.
      const schedule = await this.dataSource
        .getRepository(TrainSchedule)
        .findOne({ where: { id: dto.trainScheduleId } });
      if (!schedule) {
        throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`);
      }
      if (schedule.bookingWindowStatus !== 'OPEN') {
        throw new BadRequestException('Selected schedule is no longer accepting bookings');
      }
      if (
        schedule.originStationId !== dto.originYardId ||
        schedule.destinationStationId !== dto.destinationYardId
      ) {
        throw new BadRequestException('Selected schedule is not on the booking route');
      }
    } else if (!isGeneralContract) {
      // Day-level pool: the customer picked a DAY — require that the route has at
      // least one OPEN departure on that EAT day. The batch engine assigns the
      // train later. General contracts skip this — they have no shipment date at
      // creation; each drawdown order validates its own day.
      const day = eatDay(new Date(dto.scheduledDate!));
      const hasDeparture =
        await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
          dto.originYardId,
          dto.destinationYardId,
          day,
        );
      if (!hasDeparture) {
        throw new BadRequestException(
          'No departures available on the selected day for this route',
        );
      }
    }

    const reference = dto.reference || (await this.generateReference());
    const containers = dto.containers ?? [];
    assertFreightShape({
      freightType: dto.freightType,
      cargoTypeId: dto.cargoTypeId,
      containers,
    });

    const tradeDirection = await this.resolveTradeDirectionForBooking(
      dto.originYardId,
      dto.destinationYardId,
      dto.tradeDirection,
    );

    // Stamp the operational profile this booking belongs to (importer/exporter)
    // so the customer portal can scope lists/KPIs to the active mode. Best-effort
    // for non-government bookings with a resolved company; never blocks creation.
    let companyProfileId: string | null = null;
    if (!isGovernment && companyId) {
      let fallbackType: ProfileType | null = null;
      if (userId) {
        try {
          const { profile } =
            await this.companiesService.getCompanyInfoByUserId(userId);
          fallbackType = profile.activeProfileType ?? null;
        } catch {
          // No profile (e.g. staff creating on behalf) — fall back to mapping.
        }
      }
      companyProfileId =
        await this.companiesService.resolveCompanyProfileIdForBooking(
          companyId,
          tradeDirection,
          fallbackType,
        );
    }

    const needsConsolidation =
      dto.freightType === 'CONTAINER'
        ? await this.needsConsolidation(containers)
        : false;

    const evalInput = await this.buildEvalInput({
      freightType: dto.freightType as FreightType,
      cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
      serviceTypeId: dto.serviceTypeId,
      paymentCurrency: dto.paymentCurrency,
      tradeDirection,
      isHazardous: dto.isHazardous,
      isGovernment,
      shippingLineId: dto.shippingLineId,
      containers,
    });
    const ruleResult = await this.ruleEngineService.evaluate(evalInput);
    this.ruleEngineService.assertNoHardBlocks(ruleResult);

    warnings.push(...ruleResult.warnings);

    const booking = await this.bookingsRepository.create({
      reference,
      companyId: companyId ?? null,
      companyProfileId,
      isGovernment,
      governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
      trainId: dto.trainId,
      trainScheduleId: dto.trainScheduleId ?? null,
      contractType: dto.contractType,
      previousContractId: dto.previousContractId,
      serviceTypeId: dto.serviceTypeId,
      firstMilePickupAddress: dto.firstMilePickupAddress,
      firstMilePickupLat: dto.firstMilePickupLat ?? null,
      firstMilePickupLng: dto.firstMilePickupLng ?? null,
      lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
      lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
      lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
      customsClearingEnabled: dto.customsClearingEnabled ?? false,
      customsClearingAgent: dto.customsClearingAgent ?? null,
      equipmentReturn: dto.equipmentReturn,
      originYardId: dto.originYardId,
      destinationYardId: dto.destinationYardId,
      tradeDirection,
      freightType: dto.freightType,
      cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
      cargoFreeText: dto.cargoFreeText,
      shippingLineId: dto.shippingLineId,
      cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
      isHazardous: dto.isHazardous ?? false,
      paymentCurrency: dto.paymentCurrency,
      pnrCode: dto.pnrCode,
      financialTerms: dto.financialTerms,
      bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
      scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
      startDate: dto.startDate ? new Date(dto.startDate) : undefined,
      endDate: dto.endDate ? new Date(dto.endDate) : undefined,
      status: 'DRAFT',
      priorityScore: ruleResult.priorityScore,
      totalAmount: 0,
      paymentStatus: 'PENDING',
    });

    if (dto.freightType === 'CONTAINER') {
      await this.bookingsRepository.createContainers(
        booking.id,
        containers.map((c, i) => ({
          containerTypeId: c.containerTypeId,
          quantity: c.quantity,
          vgmPerUnitTons: c.vgmPerUnitTons,
          weightResult: ruleResult.containerWeightResults[i],
        })),
      );
      const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
      warnings.push(`Estimated wagons required: ${wagonCount}`);
    }

    // Multi-route general contracts: persist the contracted routes + quantities.
    // Each drawdown order later draws from one of these route lines.
    if (isGeneralContract && dto.routes?.length) {
      const routeRepo = this.dataSource.getRepository(ContractRouteLine);
      await routeRepo.save(
        dto.routes.map((r) =>
          routeRepo.create({
            contractBookingId: booking.id,
            originYardId: r.originYardId,
            destinationYardId: r.destinationYardId,
            containerTypeId:
              dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
            quantity: r.quantity,
            km: r.km ?? null,
          }),
        ),
      );
    }

    if (files.length > 0) {
      try {
        await this.filesService.uploadMany(booking.id, 'bookings', files);
      } catch {
        warnings.push('File upload failed — booking was created without attached files.');
      }
    }

    // Reuse the booking profile's onboarding documents instead of asking the
    // customer to re-upload. Snapshot them onto the booking now (by reference),
    // so a later active-profile switch never changes this booking's documents.
    if (companyProfileId) {
      try {
        const onboardingFiles =
          await this.companiesService.getProfileOnboardingFiles(companyProfileId);
        if (onboardingFiles.length > 0) {
          await this.filesService.attachExistingFiles(
            booking.id,
            'bookings',
            onboardingFiles.map((f, i) => ({
              code: `onboarding_document_${i + 1}`,
              name: f.name,
              url: f.url,
              size: f.size,
              mimeType: f.mimeType,
            })),
          );
        }
      } catch {
        warnings.push(
          'Could not attach onboarding documents — they can be added from the booking page.',
        );
      }
    }

    let full = await this.findById(booking.id);

    if (needsConsolidation) {
      const consolidation = await this.tryAutoConsolidate(full);
      full = consolidation.booking;
      warnings.push(...consolidation.messages);
    }

    return { booking: full, warnings };
  }

  /** Update a draft booking. */
  async update(
    id: string,
    dto: UpdateBookingDto,
    files: Express.Multer.File[],
  ): Promise<{ booking: Booking; warnings: string[] }> {
    const existing = await this.findById(id);
    if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
      throw new BadRequestException(
        'Only DRAFT or CHANGES_REQUESTED bookings can be updated',
      );
    }

    const warnings: string[] = [];
    const freightType = (dto.freightType ?? existing.freightType) as FreightType;
    let containers =
      dto.containers ??
      (existing.bookingContainers ?? [])
        .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
        .map((bc) => ({
          containerTypeId: bc.containerTypeId,
          quantity: bc.quantity,
          vgmPerUnitTons: Number(bc.vgmPerUnitTons),
        }));

    let cargoTypeId =
      dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;

    if (freightType === 'BULK') {
      containers = [];
      if (dto.containers !== undefined) {
        await this.bookingsRepository.deleteContainers(id);
      }
    } else {
      cargoTypeId = null;
      if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) {
        throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
      }
    }

    assertFreightShape({ freightType, cargoTypeId, containers });

    const originYardId = dto.originYardId ?? existing.originYardId;
    const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
    const tradeDirection = await this.resolveTradeDirectionForBooking(
      originYardId,
      destinationYardId,
      dto.tradeDirection,
    );

    const needsConsolidation =
      freightType === 'CONTAINER'
        ? await this.needsConsolidation(containers)
        : false;

    const evalInput = await this.buildEvalInput({
      freightType,
      cargoTypeId,
      serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
      paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
      tradeDirection,
      isHazardous: dto.isHazardous ?? existing.isHazardous,
      shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
      containers,
    });

    const ruleResult = await this.ruleEngineService.evaluate(evalInput);
    this.ruleEngineService.assertNoHardBlocks(ruleResult);
    warnings.push(...ruleResult.warnings);

    const pricingFieldsChanged = await this.pricingRelevantFieldsChanged(
      existing,
      dto,
      freightType,
      cargoTypeId,
      containers,
    );

    const updates: Record<string, unknown> = {
      ...dto,
      freightType,
      cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
      priorityScore: ruleResult.priorityScore,
      tradeDirection,
    };
    // If the route (hence trade direction) changed, re-stamp the operational
    // profile so an edited draft doesn't get stranded under the wrong profile.
    if (
      tradeDirection !== existing.tradeDirection &&
      !existing.isGovernment &&
      existing.companyId
    ) {
      updates.companyProfileId =
        await this.companiesService.resolveCompanyProfileIdForBooking(
          existing.companyId,
          tradeDirection,
          existing.companyProfileId
            ? undefined
            : (existing.companyProfile?.type as ProfileType | undefined),
        );
    }
    if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
    if (dto.startDate) updates.startDate = new Date(dto.startDate);
    if (dto.endDate) updates.endDate = new Date(dto.endDate);
    delete updates.containers;

    await this.bookingsRepository.update(id, updates);

    if (freightType === 'CONTAINER' && dto.containers) {
      await this.bookingsRepository.deleteContainers(id);
      await this.bookingsRepository.createContainers(
        id,
        dto.containers.map((c, i) => ({
          containerTypeId: c.containerTypeId,
          quantity: c.quantity,
          vgmPerUnitTons: c.vgmPerUnitTons,
          weightResult: ruleResult.containerWeightResults[i],
        })),
      );
    }

    if (pricingFieldsChanged) {
      await this.bookingsRepository.invalidatePricingPreview(id);
    }

    if (files.length > 0) {
      await this.filesService.uploadMany(id, 'bookings', files);
    }

    let booking = await this.findById(id);

    if (needsConsolidation && !booking.consolidationPartnerId) {
      const consolidation = await this.tryAutoConsolidate(booking);
      booking = consolidation.booking;
      warnings.push(...consolidation.messages);
    }

    return { booking, warnings };
  }

  /** Parse comma-separated scheduling status query values. */
  private parseSchedulingStatusFilter(filter: FilterBookingDto): {
    schedulingStatuses?: string[];
  } {
    const raw = filter.schedulingStatuses;
    if (!raw) return {};
    const schedulingStatuses = raw
      .split(',')
      .map((s) => s.trim())
      .filter(Boolean);
    return schedulingStatuses.length ? { schedulingStatuses } : {};
  }

  /** Parse comma-separated or repeated status query values. */
  private parseStatusFilter(filter: FilterBookingDto): {
    statuses?: string[];
    status?: string;
  } {
    const allowed = new Set<string>(BOOKING_STATUSES);
    const raw = filter.statuses;
    const statusList = raw
      ? raw
          .split(',')
          .map((s) => s.trim())
          .filter((s) => allowed.has(s))
      : [];

    if (statusList.length > 0) {
      return { statuses: statusList };
    }
    if (filter.status && allowed.has(filter.status)) {
      return { status: filter.status };
    }
    return {};
  }

  /** Return a paginated list of bookings matching the filter. */
  async findAll(
    filter: FilterBookingDto,
    forceCompanyId?: string,
    forceCompanyProfileId?: string,
  ): Promise<PaginatedBookings> {
    const page = filter.page ?? 1;
    const pageSize = filter.pageSize ?? 20;
    const statusFilter = this.parseStatusFilter(filter);
    const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);

    return this.bookingsRepository.findAllPaginated({
      page,
      pageSize,
      ...statusFilter,
      ...schedulingStatusFilter,
      assignedToSchedule: filter.assignedToSchedule,
      // A forced company scope (portal/customer) overrides any caller-provided
      // companyId so a customer can only ever see their own company's bookings.
      // The company guard always applies; the optional companyProfileId filter
      // (from the per-page service filter) narrows WITHIN the company — the repo
      // ANDs both, so cross-company access is impossible.
      companyId: forceCompanyId ?? filter.companyId,
      companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
      contractType: filter.contractType,
      serviceTypeId: filter.serviceTypeId,
      cargoTypeId: filter.cargoTypeId,
      freightType: filter.freightType,
      bookingType: filter.bookingType,
      tradeDirection: filter.tradeDirection,
      paymentCurrency: filter.paymentCurrency,
      paymentStatus: filter.paymentStatus,
      createdFrom: filter.createdFrom,
      createdTo: filter.createdTo,
      consolidationPaired: filter.consolidationPaired,
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });
  }

  /** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
  private static readonly PAYABLE_STATUSES = [
    'FULLY_EXECUTED',
    'SELECTED_FOR_BATCH',
    'AWAITING_PAYMENT',
  ];

  /**
   * List the current customer's bookings that are ready for payment:
   * payable status AND not yet PAID. Company scope is derived from the
   * authenticated user and cannot be widened by the caller.
   */
  async findMyPayable(
    userId: string,
    filter: FilterBookingDto,
  ): Promise<PaginatedBookings> {
    const { company } = await this.companiesService.getCompanyInfoByUserId(userId);

    return this.bookingsRepository.findAllPaginated({
      page: filter.page ?? 1,
      pageSize: filter.pageSize ?? 20,
      statuses: BookingsService.PAYABLE_STATUSES,
      excludePaymentStatus: 'PAID',
      // Company-wide: payables span all of the customer's services.
      companyId: company.id,
      companyProfileId: filter.companyProfileId,
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });
  }

  /**
   * Resolve the company a customer user belongs to, for scoping their own
   * bookings. Returns null when no profile/company is linked yet.
   */
  async resolveCustomerCompanyId(userId: string): Promise<string | null> {
    try {
      const { company } =
        await this.companiesService.getCompanyInfoByUserId(userId);
      return company?.id ?? null;
    } catch {
      return null;
    }
  }

  /**
   * Resolve the active company_profile id a customer's bookings should be
   * scoped to (importer/exporter mode). Null when not onboarded — callers fall
   * back to company-level scoping.
   */
  async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
    return this.companiesService.resolveActiveCompanyProfileId(userId);
  }

  /**
   * Authorize a customer's access to a single booking. Staff are scoped at the
   * controller (they pass `isStaff`); for a customer, the booking must belong
   * to the company the authenticated user is linked to — otherwise it is hidden
   * behind a NotFound so booking IDs can't be probed.
   */
  async assertCustomerCanAccessBooking(
    userId: string | undefined,
    booking: Booking,
  ): Promise<void> {
    if (!userId) {
      throw new ForbiddenException('Authentication required');
    }
    const companyId = await this.resolveCustomerCompanyId(userId);
    if (!companyId || booking.companyId !== companyId) {
      // Don't reveal that the booking exists for another company.
      throw new NotFoundException(`Booking ${booking.id} not found`);
    }
  }

  /**
   * Build the customer-facing shipment tracking payload for a booking from the
   * train schedule it is assigned to and the live checkpoint log. The caller is
   * responsible for authorizing access to the booking first.
   *
   * When the booking has not been assigned to a train yet, returns a valid
   * "no schedule" payload so the UI can show a pre-dispatch state.
   */
  async getBookingTracking(
    bookingId: string,
  ): Promise<Freight.IBookingTracking> {
    const booking = await this.findById(bookingId);

    const empty: Freight.IBookingTracking = {
      bookingId: booking.id,
      bookingReference: booking.reference,
      hasSchedule: false,
      scheduleId: null,
      trainNumber: null,
      scheduleStatus: null,
      direction: null,
      origin: null,
      destination: null,
      stations: [],
      checkpoints: [],
      currentSequenceNo: -1,
      actualDepartureAt: null,
      actualArrivalAt: null,
      scheduledDepartureAt: null,
      scheduledArrivalAt: null,
    };

    if (!booking.trainScheduleId) {
      return empty;
    }

    // Pull the live corridor + checkpoints for the assigned schedule. If the
    // schedule was removed, fall back to the pre-dispatch state rather than 500.
    let track: Awaited<
      ReturnType<TrainSchedulingService['getScheduleCheckpoints']>
    >;
    try {
      track = await this.trainSchedulingService.getScheduleCheckpoints(
        booking.trainScheduleId,
      );
    } catch {
      return empty;
    }

    return {
      bookingId: booking.id,
      bookingReference: booking.reference,
      hasSchedule: true,
      scheduleId: track.scheduleId,
      trainNumber: track.trainNumber,
      scheduleStatus: track.status as Freight.TrainScheduleStatus,
      direction: track.direction,
      origin: track.origin,
      destination: track.destination,
      stations: track.stations,
      checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
      currentSequenceNo: track.currentSequenceNo,
      actualDepartureAt: track.actualDepartureAt,
      actualArrivalAt: track.actualArrivalAt,
      scheduledDepartureAt: track.scheduledDepartureAt,
      scheduledArrivalAt: track.scheduledArrivalAt,
    };
  }

  /** Aggregate metrics and tab counts for the backoffice booking list. */
  async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
    const page = filter.page ?? 1;
    const pageSize = filter.pageSize ?? 20;
    const statusFilter = this.parseStatusFilter(filter);
    const listFilter = {
      ...statusFilter,
      companyId: filter.companyId,
      contractType: filter.contractType,
      serviceTypeId: filter.serviceTypeId,
      cargoTypeId: filter.cargoTypeId,
      freightType: filter.freightType,
      bookingType: filter.bookingType,
      tradeDirection: filter.tradeDirection,
      paymentCurrency: filter.paymentCurrency,
      paymentStatus: filter.paymentStatus,
      createdFrom: filter.createdFrom,
      createdTo: filter.createdTo,
      consolidationPaired: filter.consolidationPaired,
    };

    const [statusCounts, metrics] = await Promise.all([
      this.bookingsRepository.getStatusCounts(),
      this.bookingsRepository.getListSummaryMetrics({
        ...listFilter,
        page,
        pageSize,
        needsActionStatuses: NEEDS_ACTION_STATUSES,
        urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD,
      }),
    ]);

    return {
      metrics,
      tabs: mapStatusCountsToTabs(statusCounts),
    };
  }

  /** Get a single booking by ID with files. */
  async findById(id: string): Promise<Booking> {
    const booking = await this.bookingsRepository.findByIdWithFiles(id);
    if (!booking) {
      throw new NotFoundException(`Booking ${id} not found`);
    }

    if (booking.files && booking.files.length > 0) {
      booking.files = await Promise.all(
        booking.files.map(async (file: FileRecord) => {
          const objectName = this.minioService.getObjectNameFromUrl(file.url);
          const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
          return { ...file, signedUrl };
        }),
      );
    }

    return booking;
  }

  async findByReference(reference: string): Promise<Booking> {
    const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
    if (!booking) {
      throw new NotFoundException(`Booking with reference "${reference}" not found`);
    }
    return this.findById(booking.id);
  }

  /** Upload documents for a DRAFT booking. */
  async uploadDocuments(
    id: string,
    files: Express.Multer.File[],
  ): Promise<Booking> {
    const booking = await this.findById(id);
    if (booking.status !== 'DRAFT') {
      throw new BadRequestException(
        'Documents can only be uploaded for DRAFT bookings',
      );
    }
    await this.filesService.uploadMany(id, 'bookings', files);
    return this.findById(id);
  }

  async remove(id: string): Promise<void> {
    const booking = await this.findById(id);
    if (booking.status !== 'DRAFT') {
      throw new BadRequestException('Only DRAFT bookings can be deleted');
    }
    await this.bookingsRepository.softDelete(id);
  }

  async findQueue(
    queue: string,
    filter: FilterBookingDto,
    options?: { excludeBulk?: boolean },
  ): Promise<{ items: Booking[]; total: number }> {
    const statusMap: Record<string, string | string[]> = {
      intake: 'SUBMITTED',
      approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
      signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
      contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
      marketing: 'SIGNED_CUSTOMER',
      finance: 'FULLY_EXECUTED',
    };

    const status = statusMap[queue];
    if (!status) {
      throw new BadRequestException(`Unknown queue: ${queue}`);
    }

    return this.bookingsRepository.findQueue({
      status,
      page: filter.page,
      pageSize: filter.pageSize,
      excludeBulk: options?.excludeBulk ?? queue === 'approval',
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });
  }

  async requestConsolidation(id: string): Promise<{
    booking: Booking;
    partner: Booking | null;
    paired: boolean;
    message: string;
  }> {
    const booking = await this.findById(id);

    const needs = await this.consolidationService.needsConsolidationFromBooking(
      booking,
    );
    if (!needs) {
      throw new BadRequestException(
        'Booking already fills whole wagon(s) for all container lines; consolidation is not required',
      );
    }

    if (booking.consolidationPartnerId) {
      throw new ConflictException('Booking is already paired for consolidation');
    }

    const result = await this.tryAutoConsolidate(booking);
    const partner = result.booking.consolidationPartnerId
      ? await this.findById(result.booking.consolidationPartnerId)
      : null;

    return {
      booking: result.booking,
      partner,
      paired: partner !== null,
      message: result.messages[0] ?? '',
    };
  }

  async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> {
    const booking = await this.findById(id);
    if (!booking.consolidationPartnerId) {
      throw new BadRequestException('Booking has no consolidation partner');
    }

    const partnerId = booking.consolidationPartnerId;
    await this.bookingsRepository.unpairConsolidation(id, partnerId);

    return {
      booking: await this.findById(id),
      partner: await this.findById(partnerId),
    };
  }

  async getConsolidationDetails(id: string): Promise<{
    booking: Booking;
    partner: Booking | null;
    splitBilling: { bookingShare: number; partnerShare: number } | null;
    wagonSlots: Awaited<ReturnType<ConsolidationService['slotsFromBooking']>>;
    statusMessage: string;
  }> {
    const booking = await this.findById(id);
    const wagonSlots = await this.consolidationService.slotsFromBooking(booking);

    if (!booking.consolidationPartnerId) {
      const statusMessage =
        booking.status === 'PENDING_CONSOLIDATION'
          ? this.consolidationService.describePending(booking, wagonSlots)
          : wagonSlots.length > 0
            ? 'Consolidation may be required; no partner paired yet.'
            : 'No wagon consolidation needed.';
      return {
        booking,
        partner: null,
        splitBilling: null,
        wagonSlots,
        statusMessage,
      };
    }

    const partner = await this.findById(booking.consolidationPartnerId);
    return {
      booking,
      partner,
      splitBilling: {
        bookingShare: Number(booking.totalAmount),
        partnerShare: Number(partner.totalAmount),
      },
      wagonSlots,
      statusMessage: this.consolidationService.describePaired(
        partner.reference,
        wagonSlots,
      ),
    };
  }

  private async pricingRelevantFieldsChanged(
    existing: Booking,
    dto: UpdateBookingDto,
    freightType: FreightType,
    cargoTypeId: string | null | undefined,
    containers: CreateBookingContainerDto[],
  ): Promise<boolean> {
    if (dto.freightType !== undefined && dto.freightType !== existing.freightType) {
      return true;
    }
    if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) {
      return true;
    }
    if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) {
      return true;
    }
    if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) {
      return true;
    }
    if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) {
      return true;
    }
    if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) {
      return true;
    }
    // Container lines drive both the base price and the consolidation surcharge
    // (CONSOLIDATION_ENABLED fires on partial wagons), so any line change re-prices.
    if (dto.containers !== undefined) {
      const existingContainers = (existing.bookingContainers ?? [])
        .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
        .map((bc) => ({
          containerTypeId: bc.containerTypeId,
          quantity: bc.quantity,
          vgmPerUnitTons: Number(bc.vgmPerUnitTons),
        }));
      if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) {
        return true;
      }
    }
    if (
      freightType !== existing.freightType ||
      (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null)
    ) {
      return true;
    }
    return false;
  }

  /** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
  async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
    const booking = await this.findById(id);
    if (!booking.isGovernment) {
      throw new BadRequestException('Only government bookings can be expedited');
    }
    const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED'];
    if (blocked.includes(booking.status)) {
      throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
    }

    await this.bookingsRepository.update(id, {
      status: 'PAID',
      paymentStatus: 'PAID',
      schedulingStatus: SchedulingStatus.Eligible,
      holdStartedAt: null,
      holdExpiresAt: null,
    });
    await this.bookingsRepository.createReviewNote(
      id,
      `Government booking expedited to PAID by staff (${staffUserId})`,
      'STAFF_NOTE',
      staffUserId,
    );

    return this.findById(id);
  }

  async findCustomerBookings(companyId: string): Promise<{
    id: string;
    reference: string;
    status: string;
    tradeDirection: string;
    freightType: string;
    originLabel: string;
    destinationLabel: string;
    totalAmount: number;
    currency: string;
    scheduledDate: Date | null;
    createdAt: Date;
  }[]> {
    const { items } = await this.bookingsRepository.findAllPaginated({
      page: 1,
      pageSize: 500,
      companyId,
    });
    return items.map((b) => ({
      id: b.id,
      reference: b.reference,
      status: b.status,
      tradeDirection: b.tradeDirection,
      freightType: b.freightType,
      originLabel: b.originYard?.label ?? '',
      destinationLabel: b.destinationYard?.label ?? '',
      totalAmount: Number(b.totalAmount),
      currency: b.paymentCurrency,
      scheduledDate: b.scheduledDate ?? null,
      createdAt: b.createdAt,
    }));
  }
}
