import { BaseRepository } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';

import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
  BookingDocumentReview,
  DocumentReviewStatus,
} from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { Booking } from './entities/booking.entity';
import {
  BookingContractSignature,
  ContractSignerRole,
} from './entities/booking-contract-signature.entity';
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';

export interface BookingListFilterOptions {
  statuses?: string[];
  status?: string;
  schedulingStatuses?: string[];
  assignedToSchedule?: 'true' | 'false';
  companyId?: string;
  companyProfileId?: string;
  contractType?: string;
  serviceTypeId?: string;
  cargoTypeId?: string;
  freightType?: string;
  bookingType?: string;
  tradeDirection?: string;
  paymentCurrency?: string;
  paymentStatus?: string;
  excludePaymentStatus?: string;
  customsClearingEnabled?: boolean;
  createdFrom?: string;
  createdTo?: string;
  scheduledFrom?: string;
  scheduledTo?: string;
  originYardId?: string;
  destinationYardId?: string;
  isGovernment?: 'true' | 'false';
  consolidationPaired?: string;
}

@Injectable()
export class BookingsRepository extends BaseRepository<Booking> {
  constructor(
    @InjectRepository(Booking)
    repository: Repository<Booking>,
    private readonly dataSource: DataSource,
  ) {
    super(repository);
  }

  /** Find a booking by its human-readable reference number. */
  findByReference(reference: string): Promise<Booking | null> {
    return this.repository.findOne({ where: { reference } });
  }

  /**
   * Highest NNNNNN sequence already issued for `BK-<year>-…` references.
   * Includes soft-deleted bookings so the next number clears references that
   * still occupy the unique index. (A created-at count drifts below the issued
   * sequence after any delete and then collides forever.)
   */
  async maxReferenceSequence(year: number): Promise<number> {
    const row = await this.repository
      .createQueryBuilder('booking')
      .withDeleted()
      .select(
        "COALESCE(MAX(CAST(SUBSTRING(booking.reference FROM '[0-9]+$') AS int)), 0)",
        'max',
      )
      .where('booking.reference LIKE :prefix', { prefix: `BK-${year}-%` })
      .getRawOne<{ max: string | number | null }>();
    return Number(row?.max ?? 0);
  }

  /** Find a booking by reference with files and relations. */
  async findByReferenceWithFiles(reference: string): Promise<Booking | null> {
    return this.findByIdWithFiles(
      (
        await this.repository.findOne({ where: { reference }, select: ['id'] })
      )?.id ?? '',
    );
  }

  /** Find a booking by ID with files, containers, and config relations. */
  async findByIdWithFiles(id: string): Promise<Booking | null> {
    if (!id) return null;

    const booking = await this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.bookingContainers', 'bc')
      .leftJoinAndSelect('bc.containerType', 'ct')
      .leftJoinAndSelect('bc.units', 'bcu')
      .leftJoinAndSelect('booking.company', 'company')
      // .leftJoinAndSelect('booking.customer', 'customer')
      .leftJoinAndSelect('booking.train', 'train')
      .leftJoinAndSelect('booking.serviceType', 'st')
      .leftJoinAndSelect('booking.cargoType', 'cargo')
      .leftJoinAndSelect('booking.originYard', 'oy')
      .leftJoinAndSelect('booking.destinationYard', 'dy')
      .leftJoinAndSelect('booking.shippingLine', 'sl')
      .leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
      .leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
      .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
      .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
      .where('booking.id = :id', { id })
      .addOrderBy('bcu.sort_order', 'ASC')
      .leftJoinAndMapMany(
        'booking.files',
        FileRecord,
        'file',
        // Superseded versions are soft-deleted, not dropped — keep them out of
        // the live file list (a manual join condition is not filtered for us).
        "file.resource_id = booking.id AND file.resource = 'bookings' AND file.deleted_at IS NULL",
      )
      .getOne();

    return booking ?? null;
  }

  /** Persist booking container rows with weight rule results. */
  async createContainers(
    bookingId: string,
    containers: Array<{
      containerTypeId: string;
      quantity: number;
      vgmPerUnitTons: number;
      hazardousQuantity?: number;
      reeferQuantity?: number;
      weightResult: ContainerWeightResult;
    }>,
  ): Promise<BookingContainer[]> {
    const containerRepo = this.dataSource.getRepository(BookingContainer);
    const typeRepo = this.dataSource.getRepository(ContainerType);
    const saved: BookingContainer[] = [];

    for (const item of containers) {
      const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } });
      const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt);
      const totalVgm = item.quantity * item.vgmPerUnitTons;
      const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
      // A per-line breakdown can never exceed the line's own quantity.
      const clamp = (v?: number) =>
        Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0));

      const row = containerRepo.create({
        bookingId,
        containerTypeId: item.containerTypeId,
        quantity: item.quantity,
        hazardousQuantity: clamp(item.hazardousQuantity),
        reeferQuantity: clamp(item.reeferQuantity),
        vgmPerUnitTons: item.vgmPerUnitTons,
        totalVgmTons: totalVgm,
        wagonsRequired,
        weightLimitRuleId: item.weightResult.weightLimitRuleId,
        isOverweight: item.weightResult.isOverweight,
        overweightExcessTons: item.weightResult.overweightExcessTons,
      });
      saved.push(await containerRepo.save(row));
    }

    return saved;
  }

  /** SQL aggregate wagon count for a booking. */
  async calculateWagonCount(bookingId: string): Promise<number> {
    const result = await this.dataSource
      .createQueryBuilder()
      .select(
        'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))',
        'total',
      )
      .from(BookingContainer, 'bc')
      .innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
      .where('bc.booking_id = :bookingId', { bookingId })
      .getRawOne<{ total: string }>();

    return Number(result?.total ?? 0);
  }

  /**
   * The road billing distance (km) of a booking's contract route, used to price
   * per-km first/last-mile trucking. Returns 0 when there is no route or no km
   * recorded (rail-only lanes) so a PER_KM rate bills nothing.
   */
  async getContractRouteKm(contractRouteId: string | null | undefined): Promise<number> {
    if (!contractRouteId) return 0;
    const route = await this.dataSource
      .getRepository(ContractRoute)
      .findOne({ where: { id: contractRouteId }, select: { id: true, km: true } });
    return Number(route?.km ?? 0);
  }

  /**
   * Frozen contract unit-rate snapshots for a contract (H15). A booking created
   * under a contract prices from these agreed, frozen rates rather than the live
   * rate of the day; the pricing service matches them by rate code.
   */
  findContractRateSnapshots(
    contractId: string,
  ): Promise<ContractRateSnapshot[]> {
    return this.dataSource
      .getRepository(ContractRateSnapshot)
      .find({ where: { contractId } });
  }

  /**
   * Find another booking whose container quantity complements this one to fill whole wagon(s)
   * (same route, same container type, partial wagon on both sides). Only 20ft lines ever
   * reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial.
   *
   * Partners must also ride the SAME booking day: consolidation shares one physical wagon,
   * and the window/batch pool is keyed on the EAT departure day, so a pair that can't board
   * the same train is useless. The day filter is applied only when THIS booking already has
   * a scheduled_date (draft bookings without a date match on route/type alone until they pick one).
   */
  async findComplementaryConsolidationPartner(
    booking: Booking,
    slot: {
      containerTypeId: string;
      quantity: number;
      containersPerWagon: number;
    },
    manager?: EntityManager,
  ): Promise<Booking | null> {
    const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;

    const repo = manager ? manager.getRepository(Booking) : this.repository;
    const qb = repo
      .createQueryBuilder('b')
      .innerJoinAndSelect('b.bookingContainers', 'bc')
      .innerJoin('bc.containerType', 'ct')
      .where('b.id != :bookingId', { bookingId: booking.id })
      .andWhere('b.consolidationPartnerId IS NULL')
      // Only pair bookings the customer has committed (SUBMITTED) or that are
      // already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
      // pairing never prematurely submits an unfinished/unpriced draft.
      .andWhere('b.status IN (:...statuses)', {
        statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
      })
      .andWhere('b.originYardId = :originYardId', {
        originYardId: booking.originYardId,
      })
      .andWhere('b.destinationYardId = :destinationYardId', {
        destinationYardId: booking.destinationYardId,
      })
      .andWhere('b.tradeDirection = :tradeDirection', {
        tradeDirection: booking.tradeDirection,
      })
      .andWhere('bc.containerTypeId = :containerTypeId', { containerTypeId })
      .andWhere('(bc.quantity % :perWagon) > 0', { perWagon })
      .andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
        quantity,
        perWagon,
      });

    // Same EAT booking day, so the pair can share a wagon on one train. Skip only
    // when this booking has no date yet (matched again once it picks its day).
    if (booking.scheduledDate) {
      qb.andWhere(
        `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
        { bookingDate: booking.scheduledDate },
      );
    }

    qb.orderBy('b.createdAt', 'ASC');

    // H9: under the caller's transaction, take a write lock on the matched
    // partner booking row (FOR UPDATE OF b — booking rows only, not the joined
    // reference tables) so a concurrent consolidation cannot claim the same
    // partner between this find and the pair write. Only when a transaction
    // manager is supplied — a pessimistic lock requires an open transaction.
    if (manager) {
      qb.setLock('pessimistic_write', undefined, ['b']);
    }

    return qb.getOne();
  }

  /** Try each partial-wagon line until a complementary partner booking is found. */
  async findConsolidationPartner(
    booking: Booking,
    slots: Array<{
      containerTypeId: string;
      quantity: number;
      containersPerWagon: number;
    }>,
    manager?: EntityManager,
  ): Promise<Booking | null> {
    for (const slot of slots) {
      const partner = await this.findComplementaryConsolidationPartner(
        booking,
        slot,
        manager,
      );
      if (partner) return partner;
    }
    return null;
  }

  /**
   * Pair two bookings for consolidation. Each returns to its own resume status —
   * SUBMITTED for a direct customer booking (so staff can accept it into the
   * approval chain) or the stored consolidationResumeStatus for a contract
   * drawdown (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS). The link itself
   * (consolidationPartnerId) marks them as consolidated in the UI. The resume
   * status is cleared once used, so a later un-pair re-parks cleanly.
   */
  async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
    const [booking, partner] = await Promise.all([
      this.repository.findOne({
        where: { id: bookingId },
        select: { id: true, consolidationResumeStatus: true },
      }),
      this.repository.findOne({
        where: { id: partnerId },
        select: { id: true, consolidationResumeStatus: true },
      }),
    ]);

    await this.repository.update(bookingId, {
      consolidationPartnerId: partnerId,
      status: booking?.consolidationResumeStatus ?? 'SUBMITTED',
      consolidationResumeStatus: null,
    } as never);
    await this.repository.update(partnerId, {
      consolidationPartnerId: bookingId,
      status: partner?.consolidationResumeStatus ?? 'SUBMITTED',
      consolidationResumeStatus: null,
    } as never);
  }

  /**
   * Race-safe pairing (H9): the transactional counterpart of
   * {@link pairConsolidation}. Must run inside the caller's transaction
   * (`manager`), which should already hold the partner-row write lock taken by
   * {@link findComplementaryConsolidationPartner}. Re-reads both rows and
   * re-asserts `consolidationPartnerId IS NULL` on each before writing; returns
   * `false` (no write) when either booking was already paired by a concurrent
   * flow, so the caller can fall back to parking.
   */
  async pairConsolidationIfUnpaired(
    bookingId: string,
    partnerId: string,
    manager: EntityManager,
  ): Promise<boolean> {
    const repo = manager.getRepository(Booking);
    // Sequential (one connection per transaction) — never Promise.all here.
    const booking = await repo.findOne({
      where: { id: bookingId },
      select: {
        id: true,
        consolidationPartnerId: true,
        consolidationResumeStatus: true,
      },
    });
    const partner = await repo.findOne({
      where: { id: partnerId },
      select: {
        id: true,
        consolidationPartnerId: true,
        consolidationResumeStatus: true,
      },
    });

    // Re-assert both are still unpaired before writing (the partner row is held
    // under the finder's write lock, so its state is stable here).
    if (
      !booking ||
      !partner ||
      booking.consolidationPartnerId != null ||
      partner.consolidationPartnerId != null
    ) {
      return false;
    }

    await repo.update(bookingId, {
      consolidationPartnerId: partnerId,
      status: booking.consolidationResumeStatus ?? 'SUBMITTED',
      consolidationResumeStatus: null,
    } as never);
    await repo.update(partnerId, {
      consolidationPartnerId: bookingId,
      status: partner.consolidationResumeStatus ?? 'SUBMITTED',
      consolidationResumeStatus: null,
    } as never);
    return true;
  }

  /**
   * Park a booking that needs consolidation but has no partner yet. The optional
   * resumeStatus is where the booking returns once it pairs — pass it for a
   * contract drawdown so pairing resumes the contract-booking flow rather than
   * the direct-booking SUBMITTED default.
   */
  async parkForConsolidation(
    bookingId: string,
    resumeStatus?: string | null,
  ): Promise<void> {
    await this.repository.update(bookingId, {
      consolidationPartnerId: null,
      status: 'PENDING_CONSOLIDATION',
      consolidationResumeStatus: resumeStatus ?? null,
    } as never);
  }

  /** Un-pair a consolidation. */
  async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
    await this.repository.update(bookingId, {
      consolidationPartnerId: null,
      status: 'PENDING_CONSOLIDATION',
    } as never);
    await this.repository.update(partnerId, {
      consolidationPartnerId: null,
      status: 'PENDING_CONSOLIDATION',
    } as never);
  }

  /** Delete all containers for a booking (used on draft update). */
  async deleteContainers(bookingId: string): Promise<void> {
    await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
  }

  // ── Clearance document reviews ────────────────────────────────────────────

  findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
    return this.dataSource.getRepository(BookingDocumentReview).find({
      where: { bookingId },
      order: { createdAt: 'ASC' },
    });
  }

  findDocumentReview(
    bookingId: string,
    settingCode: string,
    fileKey: string,
  ): Promise<BookingDocumentReview | null> {
    return this.dataSource.getRepository(BookingDocumentReview).findOne({
      where: { bookingId, settingCode, fileKey },
    });
  }

  /**
   * Upsert a document-review row to PENDING for a freshly uploaded file. Resets
   * any prior QUERIED/APPROVED state so the GL re-reviews the new upload.
   */
  async upsertDocumentReviewPending(input: {
    bookingId: string;
    settingCode: string;
    fileKey: string;
    fileRecordId: string;
  }): Promise<void> {
    const repo = this.dataSource.getRepository(BookingDocumentReview);
    const existing = await repo.findOne({
      where: {
        bookingId: input.bookingId,
        settingCode: input.settingCode,
        fileKey: input.fileKey,
      },
    });
    if (existing) {
      await repo.update(existing.id, {
        fileRecordId: input.fileRecordId,
        status: 'PENDING',
        note: null,
        reviewedByStaffId: null,
        reviewedAt: null,
      });
      return;
    }
    await repo.save(repo.create({ ...input, status: 'PENDING' }));
  }

  /** GL marks a document APPROVED or QUERIED (with an optional note). */
  async setDocumentReviewStatus(
    bookingId: string,
    settingCode: string,
    fileKey: string,
    status: DocumentReviewStatus,
    staffId: string,
    note?: string,
  ): Promise<void> {
    const repo = this.dataSource.getRepository(BookingDocumentReview);
    const existing = await repo.findOne({
      where: { bookingId, settingCode, fileKey },
    });
    const patch = {
      status,
      note: note ?? null,
      reviewedByStaffId: staffId,
      reviewedAt: new Date(),
    };
    if (existing) {
      await repo.update(existing.id, patch);
      return;
    }
    await repo.save(repo.create({ bookingId, settingCode, fileKey, ...patch }));
  }

  /** Persist cargo modifiers linked to rate snapshots. */
  async createCargoModifiers(
    rows: Array<{
      bookingId: string;
      rateId: string;
      triggerValue: number | null;
      calculatedAmount: number;
      rateSnapshotId: string;
    }>,
  ): Promise<BookingCargoModifier[]> {
    const repo = this.dataSource.getRepository(BookingCargoModifier);
    const saved: BookingCargoModifier[] = [];
    for (const row of rows) {
      saved.push(await repo.save(repo.create(row)));
    }
    return saved;
  }

  /** Find rate snapshot by rate id for a booking. */
  async findRateSnapshotByRateId(
    bookingId: string,
    rateId: string,
  ): Promise<BookingRateSnapshot | null> {
    return this.dataSource.getRepository(BookingRateSnapshot).findOne({
      where: { bookingId, rateId },
    });
  }

  async createReviewNote(
    bookingId: string,
    note: string,
    type: ReviewNoteType,
    authorId?: string,
  ): Promise<BookingReviewNote> {
    const repo = this.dataSource.getRepository(BookingReviewNote);
    return repo.save(
      repo.create({ bookingId, note, type, authorId: authorId ?? null }),
    );
  }

  async findLatestReviewNote(
    bookingId: string,
    type?: ReviewNoteType,
  ): Promise<BookingReviewNote | null> {
    const repo = this.dataSource.getRepository(BookingReviewNote);
    return repo.findOne({
      where: type ? { bookingId, type } : { bookingId },
      order: { createdAt: 'DESC' },
    });
  }

  async clearPricingArtifacts(bookingId: string): Promise<void> {
    await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId });
    await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
  }

  async hasPricingArtifacts(bookingId: string): Promise<boolean> {
    const snapshotCount = await this.dataSource
      .getRepository(BookingRateSnapshot)
      .count({ where: { bookingId } });
    const modifierCount = await this.dataSource
      .getRepository(BookingCargoModifier)
      .count({ where: { bookingId } });
    return snapshotCount > 0 || modifierCount > 0;
  }

  async invalidatePricingPreview(bookingId: string): Promise<void> {
    if (await this.hasPricingArtifacts(bookingId)) {
      await this.clearPricingArtifacts(bookingId);
    }
    await this.update(bookingId, {
      totalAmount: 0,
      pricingBreakdown: null,
    } as never);
  }

  /** Bookings in any of the given statuses (clearance queue helpers). */
  async findByStatuses(statuses: string[]): Promise<Booking[]> {
    if (!statuses.length) return [];
    return this.repository.find({
      where: { status: In(statuses) },
      relations: {
        company: true,
        originYard: true,
        destinationYard: true,
      },
      order: { createdAt: 'DESC' },
    });
  }

  /** Queue listing with optional bulk exclusion for LINE_STAFF. */
  async findQueue(options: {
    status: string | string[];
    page?: number;
    pageSize?: number;
    excludeBulk?: boolean;
    sortBy?: string;
    sortOrder?: 'ASC' | 'DESC';
  }): Promise<{ items: Booking[]; total: number }> {
    const page = options.page ?? 1;
    const pageSize = options.pageSize ?? 20;
    const statuses = Array.isArray(options.status) ? options.status : [options.status];

    const qb = this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.originYard', 'originYard')
      .leftJoinAndSelect('booking.destinationYard', 'destinationYard')
      .leftJoinAndSelect('booking.cargoType', 'cargo')
      .leftJoinAndSelect('booking.serviceType', 'serviceType')
      .where('booking.status IN (:...statuses)', { statuses });

    if (options.excludeBulk) {
      qb.andWhere("booking.freight_type = 'CONTAINER'");
    }

    const sortField =
      options.sortBy === 'priorityScore'
        ? 'booking.priorityScore'
        : 'booking.createdAt';
    qb.orderBy(sortField, options.sortOrder ?? 'DESC');

    const [items, total] = await qb
      .skip((page - 1) * pageSize)
      .take(pageSize)
      .getManyAndCount();

    return { items, total };
  }

  /** Paginated list with optional multi-status filter (API tab queues). */
  async findAllPaginated(options: BookingListFilterOptions & {
    page: number;
    pageSize: number;
    search?: string;
    sortBy?: string;
    sortOrder?: 'ASC' | 'DESC';
  }): Promise<{
    items: Booking[];
    total: number;
    meta: {
      page: number;
      pageSize: number;
      total: number;
      totalPages: number;
      hasNextPage: boolean;
      hasPreviousPage: boolean;
    };
  }> {
    const page = options.page;
    const pageSize = options.pageSize;

    const qb = this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.originYard', 'originYard')
      .leftJoinAndSelect('booking.destinationYard', 'destinationYard')
      .leftJoinAndSelect('booking.serviceType', 'serviceType')
      .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
      // Contract reference for the list column + search (no entity relation on
      // Booking → contract, so join the entity by id and select just the
      // reference — a schema-qualified table string is parsed as alias.relation
      // by TypeORM and crashes).
      .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
      .addSelect('contract.reference', 'contract_reference')
      .where('booking.deleted_at IS NULL');

    this.applyListFilters(qb, options);

    // Free-text search spans joined columns (company, contract) that only this
    // list query joins — so it lives here, not in applyListFilters (shared
    // with getListSummaryMetrics, whose query builder has no joins).
    if (options.search) {
      qb.andWhere(
        '(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)',
        { search: `%${options.search}%` },
      );
    }

    if (options.sortBy === 'isGovernment') {
      qb.orderBy('booking.isGovernment', 'DESC')
        .addOrderBy('booking.priorityScore', 'DESC')
        .addOrderBy('booking.scheduledDate', 'ASC');
    } else {
      const sortField =
        options.sortBy === 'priorityScore'
          ? 'booking.priorityScore'
          : options.sortBy === 'scheduledDate'
            ? 'booking.scheduledDate'
            : 'booking.createdAt';
      qb.orderBy(sortField, options.sortOrder ?? 'DESC');
    }

    const total = await qb.getCount();
    const { entities: items, raw } = await qb
      .skip((page - 1) * pageSize)
      .take(pageSize)
      .getRawAndEntities();

    // The joined contract.reference comes back on the raw rows only (entity has no
    // contract relation) — map it onto each booking by position.
    const contractRefByBooking = new Map<string, string | null>();
    for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) {
      if (row.booking_id && !contractRefByBooking.has(row.booking_id)) {
        contractRefByBooking.set(row.booking_id, row.contract_reference ?? null);
      }
    }
    for (const item of items) {
      (item as Booking & { contractReference?: string | null }).contractReference =
        contractRefByBooking.get(item.id) ?? null;
    }

    if (items.length) {
      const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
        where: { bookingId: In(items.map((item) => item.id)) },
        select: { bookingId: true, trainScheduleId: true },
      });
      const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId]));
      for (const item of items) {
        (item as Booking & { trainScheduleId?: string | null }).trainScheduleId =
          scheduleByBooking.get(item.id) ?? null;
      }
    }

    const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
    // Return both the flat `total` (consumed by the backoffice list) and a
    // `meta` block (consumed by the portal, matching PaginationMeta) so neither
    // app needs to change its read shape.
    return {
      items,
      total,
      meta: {
        page,
        pageSize,
        total,
        totalPages,
        hasNextPage: page < totalPages,
        hasPreviousPage: page > 1,
      },
    };
  }

  async getStatusCounts(): Promise<Record<string, number>> {
    const rows = await this.repository
      .createQueryBuilder('booking')
      .select('booking.status', 'status')
      .addSelect('COUNT(*)::int', 'count')
      .where('booking.deleted_at IS NULL')
      .groupBy('booking.status')
      .getRawMany<{ status: string; count: string }>();

    return Object.fromEntries(
      rows.map((row) => [row.status, Number(row.count)]),
    );
  }

  async getListSummaryMetrics(
    options: BookingListFilterOptions & {
      page: number;
      pageSize: number;
      needsActionStatuses: readonly string[];
      urgentPriorityThreshold: number;
    },
  ): Promise<{
    inQueue: number;
    onThisPage: number;
    needsAction: number;
    urgent: number;
  }> {
    const baseQb = () => {
      const qb = this.repository
        .createQueryBuilder('booking')
        .where('booking.deleted_at IS NULL');
      this.applyListFilters(qb, options);
      return qb;
    };

    const inQueue = await baseQb().getCount();

    const needsAction = await baseQb()
      .andWhere('booking.status IN (:...needsActionStatuses)', {
        needsActionStatuses: [...options.needsActionStatuses],
      })
      .getCount();

    const urgent = await baseQb()
      .andWhere('booking.priority_score >= :urgentPriorityThreshold', {
        urgentPriorityThreshold: options.urgentPriorityThreshold,
      })
      .getCount();

    const offset = (options.page - 1) * options.pageSize;
    const onThisPage = Math.min(
      options.pageSize,
      Math.max(0, inQueue - offset),
    );

    return { inQueue, onThisPage, needsAction, urgent };
  }

  private applyListFilters(
    qb: SelectQueryBuilder<Booking>,
    options: BookingListFilterOptions,
  ): void {
    if (options.statuses?.length) {
      qb.andWhere('booking.status IN (:...statuses)', {
        statuses: options.statuses,
      });
    } else if (options.status) {
      qb.andWhere('booking.status = :status', { status: options.status });
    }

    if (options.companyId) {
      qb.andWhere('booking.company_id = :companyId', {
        companyId: options.companyId,
      });
    }
    if (options.companyProfileId) {
      qb.andWhere('booking.company_profile_id = :companyProfileId', {
        companyProfileId: options.companyProfileId,
      });
    }
    if (options.contractType) {
      qb.andWhere('booking.contract_type = :contractType', {
        contractType: options.contractType,
      });
    }
    if (options.serviceTypeId) {
      qb.andWhere('booking.service_type_id = :serviceTypeId', {
        serviceTypeId: options.serviceTypeId,
      });
    }
    if (options.cargoTypeId) {
      qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
        cargoTypeId: options.cargoTypeId,
      });
    }
    if (options.freightType) {
      qb.andWhere('booking.freight_type = :freightType', {
        freightType: options.freightType,
      });
    }
    if (options.bookingType) {
      // The stored booking_type column is 'ONE_TIME' for every row (contract
      // drawdowns included — see contract-booking.service create), so the
      // one-time vs general split keys on the denormalized contract_kind:
      // GENERAL_CONTRACT tab = bookings under a GENERAL contract, ONE_TIME tab
      // = everything else (ONE_TIME contracts and legacy contract-less rows).
      if (options.bookingType === 'GENERAL_CONTRACT') {
        qb.andWhere("booking.contract_kind = 'GENERAL'");
      } else {
        qb.andWhere("booking.contract_kind IS DISTINCT FROM 'GENERAL'");
      }
    }
    if (options.createdFrom) {
      qb.andWhere('booking.created_at >= :createdFrom', {
        createdFrom: options.createdFrom,
      });
    }
    if (options.createdTo) {
      // Inclusive end-of-day: callers pass a date; include the whole day.
      qb.andWhere('booking.created_at <= :createdTo', {
        createdTo: options.createdTo,
      });
    }
    if (options.scheduledFrom) {
      qb.andWhere('booking.scheduled_date >= :scheduledFrom', {
        scheduledFrom: options.scheduledFrom,
      });
    }
    if (options.scheduledTo) {
      // Inclusive end-of-day: callers pass a date; include the whole day.
      qb.andWhere('booking.scheduled_date <= :scheduledTo', {
        scheduledTo: options.scheduledTo,
      });
    }
    if (options.originYardId) {
      qb.andWhere('booking.origin_yard_id = :originYardId', {
        originYardId: options.originYardId,
      });
    }
    if (options.destinationYardId) {
      qb.andWhere('booking.destination_yard_id = :destinationYardId', {
        destinationYardId: options.destinationYardId,
      });
    }
    if (options.isGovernment === 'true') {
      qb.andWhere('booking.is_government = TRUE');
    } else if (options.isGovernment === 'false') {
      qb.andWhere('booking.is_government = FALSE');
    }
    if (options.tradeDirection) {
      qb.andWhere('booking.trade_direction = :tradeDirection', {
        tradeDirection: options.tradeDirection,
      });
    }
    if (options.paymentCurrency) {
      qb.andWhere('booking.payment_currency = :paymentCurrency', {
        paymentCurrency: options.paymentCurrency,
      });
    }
    if (options.paymentStatus) {
      qb.andWhere('booking.payment_status = :paymentStatus', {
        paymentStatus: options.paymentStatus,
      });
    }
    if (options.excludePaymentStatus) {
      qb.andWhere('booking.payment_status != :excludePaymentStatus', {
        excludePaymentStatus: options.excludePaymentStatus,
      });
    }
    if (options.customsClearingEnabled !== undefined) {
      qb.andWhere('booking.customs_clearing_enabled = :customsClearingEnabled', {
        customsClearingEnabled: options.customsClearingEnabled,
      });
    }
    if (options.consolidationPaired === 'true') {
      qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
    } else if (options.consolidationPaired === 'false') {
      qb.andWhere('booking.consolidation_partner_id IS NULL');
    }
    if (options.schedulingStatuses?.length) {
      qb.andWhere('booking.scheduling_status IN (:...schedulingStatuses)', {
        schedulingStatuses: options.schedulingStatuses,
      });
    }
    if (options.assignedToSchedule === 'true') {
      qb.andWhere(
        `EXISTS (
          SELECT 1 FROM freight.train_schedule_bookings tsb
          WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
        )`,
      );
    } else if (options.assignedToSchedule === 'false') {
      qb.andWhere(
        `NOT EXISTS (
          SELECT 1 FROM freight.train_schedule_bookings tsb
          WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
        )`,
      );
    }
  }

  async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
    skip: number;
    take: number;
    order: Record<string, 'ASC' | 'DESC'>;
  }): Promise<[Booking[], number]> {
    return this.repository.findAndCount({
      where,
      skip: options.skip,
      take: options.take,
      order: options.order,
    });
  }

  findContractSignatures(bookingId: string): Promise<BookingContractSignature[]> {
    return this.dataSource.getRepository(BookingContractSignature).find({
      where: { bookingId },
      relations: ['signatureFile'],
      order: { signedAt: 'ASC' },
    });
  }

  findContractSignature(
    bookingId: string,
    role: ContractSignerRole,
  ): Promise<BookingContractSignature | null> {
    return this.dataSource.getRepository(BookingContractSignature).findOne({
      where: { bookingId, signerRole: role },
      relations: ['signatureFile'],
    });
  }

  async saveContractSignature(
    data: Partial<BookingContractSignature>,
  ): Promise<BookingContractSignature> {
    const repo = this.dataSource.getRepository(BookingContractSignature);
    const existing = await repo.findOne({
      where: {
        bookingId: data.bookingId!,
        signerRole: data.signerRole!,
      },
    });
    if (existing) {
      Object.assign(existing, data);
      return repo.save(existing);
    }
    return repo.save(repo.create(data));
  }

  private bookingRepo(manager?: EntityManager) {
    return manager ? manager.getRepository(Booking) : this.repository;
  }

  findEligibleForScheduling(options: {
    freightType?: string;
    originStationId?: string;
    destinationStationId?: string;
    schedulingStatus?: string;
    trainScheduleId?: string;
    /**
     * EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees
     * the whole (route, day) pool rather than bookings pre-targeted to one train.
     */
    day?: string;
  }): Promise<Booking[]> {
    const qb = this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.originYard', 'originYard')
      .leftJoinAndSelect('booking.destinationYard', 'destinationYard')
      .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
      .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
      .leftJoinAndSelect('booking.cargoType', 'cargoType')
      .leftJoin(
        TrainScheduleBooking,
        'scheduleBooking',
        'scheduleBooking.booking_id = booking.id',
      )
      .where('booking.status = :paidStatus', { paidStatus: 'PAID' })
      .andWhere('scheduleBooking.id IS NULL');

    // Day-level pooling: customers no longer set train_schedule_id, so the wizard
    // surfaces the whole (route, EAT day) pool. Fall back to the legacy
    // single-schedule filter only when no day is supplied (e.g. a staff-pinned
    // booking that still carries train_schedule_id).
    if (options.day) {
      qb.andWhere(
        `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
        { day: options.day },
      );
    } else if (options.trainScheduleId) {
      qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
        trainScheduleId: options.trainScheduleId,
      });
    }

    if (options.freightType) {
      qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
    }

    if (options.originStationId) {
      qb.andWhere('booking.originYardId = :originStationId', {
        originStationId: options.originStationId,
      });
    }
    if (options.destinationStationId) {
      qb.andWhere('booking.destinationYardId = :destinationStationId', {
        destinationStationId: options.destinationStationId,
      });
    }
    if (options.schedulingStatus) {
      qb.andWhere('booking.scheduling_status = :schedulingStatus', {
        schedulingStatus: options.schedulingStatus,
      });
    }

    return qb
      .orderBy('booking.priority_score', 'DESC')
      .addOrderBy('booking.scheduled_date', 'ASC')
      .addOrderBy('booking.created_at', 'ASC')
      .getMany();
  }

  /**
   * Ready, not-yet-allocated bookings targeting a schedule (the batch pool).
   * Commercial = FULLY_EXECUTED; government = APPROVED or PAID (skips contract).
   * Ordered government → priority → contract-sign time.
   */
  findBatchPool(scheduleId: string): Promise<Booking[]> {
    return this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
      .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
      .leftJoinAndSelect('booking.cargoType', 'cargoType')
      .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
      .where('booking.train_schedule_id = :scheduleId', { scheduleId })
      .andWhere('sb.id IS NULL')
      .andWhere(
        `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
          OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
      )
      .orderBy('booking.is_government', 'DESC')
      .addOrderBy('booking.priority_score', 'DESC')
      .addOrderBy('booking.fully_executed_at', 'ASC')
      .addOrderBy('booking.created_at', 'ASC')
      .getMany();
  }

  /**
   * Day-level batch pool: ready, not-yet-allocated bookings on a route for one
   * EAT calendar day, regardless of which train they end up on. Same status
   * rules and ordering as {@link findBatchPool}, but keyed on
   * (origin, destination, day) instead of train_schedule_id — the engine then
   * distributes these across all trains departing that day.
   */
  findBatchPoolByRouteDay(
    originYardId: string,
    destinationYardId: string,
    day: string,
  ): Promise<Booking[]> {
    return this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
      .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
      .leftJoinAndSelect('booking.cargoType', 'cargoType')
      .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
      .where('booking.origin_yard_id = :originYardId', { originYardId })
      .andWhere('booking.destination_yard_id = :destinationYardId', {
        destinationYardId,
      })
      .andWhere(
        `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
        { day },
      )
      .andWhere('sb.id IS NULL')
      .andWhere(
        `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
          OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
      )
      .orderBy('booking.is_government', 'DESC')
      .addOrderBy('booking.priority_score', 'DESC')
      .addOrderBy('booking.fully_executed_at', 'ASC')
      .addOrderBy('booking.created_at', 'ASC')
      .getMany();
  }

  /**
   * Corridor day pool: ready, not-yet-allocated bookings for one EAT day whose
   * origin AND destination both lie on the day's corridor stop set — covers
   * full-route bookings and sub-corridor bookings (Dire→Djibouti on an
   * Addis→…→Djibouti train). The caller still verifies stop ORDER per train
   * via the corridor budget; this query only narrows the pool. Same status
   * rules and ordering as {@link findBatchPool}.
   */
  findBatchPoolByCorridorDay(
    corridorYardIds: string[],
    day: string,
  ): Promise<Booking[]> {
    if (corridorYardIds.length === 0) return Promise.resolve([]);
    return this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
      .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
      .leftJoinAndSelect('booking.cargoType', 'cargoType')
      .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
      .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
      .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
        corridorYardIds,
      })
      .andWhere(
        `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
        { day },
      )
      .andWhere('sb.id IS NULL')
      .andWhere(
        `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
          OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
      )
      .orderBy('booking.is_government', 'DESC')
      .addOrderBy('booking.priority_score', 'DESC')
      .addOrderBy('booking.fully_executed_at', 'ASC')
      .addOrderBy('booking.created_at', 'ASC')
      .getMany();
  }

  /**
   * Commercial bookings on the day's corridor whose operation request was NOT
   * accepted by staff (still pending / changes / price-confirm) and are not yet
   * linked to a train. These never reached FULLY_EXECUTED, so they never enter the
   * batch pool; the window's doc-review end sweeps them to EXPIRED. Government
   * bookings are excluded (they don't go through the customer window).
   */
  findUnacceptedForRouteDay(
    corridorYardIds: string[],
    day: string,
  ): Promise<Booking[]> {
    if (corridorYardIds.length === 0) return Promise.resolve([]);
    return this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
      .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
      .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
        corridorYardIds,
      })
      .andWhere(
        `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
        { day },
      )
      .andWhere('sb.id IS NULL')
      .andWhere('booking.is_government = false')
      .andWhere(
        `booking.status IN (
          'OPERATION_REQUESTED',
          'OPERATION_REQUEST_PENDING',
          'OPERATION_CHANGES_REQUESTED',
          'OPERATION_PRICE_PENDING_CONFIRM'
        )`,
      )
      .getMany();
  }

  /** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
  findAllBySchedule(scheduleId: string): Promise<Booking[]> {
    return this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
      .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
      .leftJoinAndSelect('booking.cargoType', 'cargoType')
      .where('booking.train_schedule_id = :scheduleId', { scheduleId })
      .orderBy('booking.is_government', 'DESC')
      .addOrderBy('booking.priority_score', 'DESC')
      .addOrderBy('booking.created_at', 'ASC')
      .getMany();
  }

  /** Same as {@link findAllBySchedule} but for a page of schedules at once —
   * one query instead of one per schedule (batch monitoring board). */
  findAllBySchedules(scheduleIds: string[]): Promise<Booking[]> {
    if (!scheduleIds.length) return Promise.resolve([]);
    return this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
      .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
      .leftJoinAndSelect('booking.cargoType', 'cargoType')
      .where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds })
      .orderBy('booking.is_government', 'DESC')
      .addOrderBy('booking.priority_score', 'DESC')
      .addOrderBy('booking.created_at', 'ASC')
      .getMany();
  }

  /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
  findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
    return this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
      .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
      .leftJoinAndSelect('booking.cargoType', 'cargoType')
      .where('booking.train_schedule_id = :scheduleId', { scheduleId })
      .andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
      .getMany();
  }

  /** PAID bookings targeting a schedule that have no train_schedule_bookings link yet. */
  findPaidUnlinkedForSchedule(scheduleId: string): Promise<Booking[]> {
    return this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
      .leftJoin(
        TrainScheduleBooking,
        'scheduleBooking',
        'scheduleBooking.booking_id = booking.id',
      )
      .where('booking.train_schedule_id = :scheduleId', { scheduleId })
      .andWhere(`booking.status = 'PAID'`)
      .andWhere('scheduleBooking.id IS NULL')
      .orderBy('booking.priority_score', 'DESC')
      .addOrderBy('booking.created_at', 'ASC')
      .getMany();
  }

  /** Commercial bookings already allocated to a schedule, lowest-priority first (for government preempt). */
  findAllocatedCommercialForSchedule(scheduleId: string): Promise<Booking[]> {
    return this.repository
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
      .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
      .leftJoinAndSelect('booking.cargoType', 'cargoType')
      .innerJoin(
        TrainScheduleBooking,
        'sb',
        'sb.booking_id = booking.id AND sb.train_schedule_id = :scheduleId',
        { scheduleId },
      )
      .where('booking.is_government = false')
      .orderBy('booking.priority_score', 'ASC')
      .addOrderBy('booking.created_at', 'DESC')
      .getMany();
  }

  findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise<Booking[]> {
    if (!bookingIds.length) return Promise.resolve([]);
    return this.bookingRepo(manager).find({
      where: { id: In(bookingIds) },
      // Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins
      // multiply rows badly in a single join (hot path for every allocation
      // preview / assignment validation).
      relationLoadStrategy: 'query',
      relations: {
        company: true,
        originYard: true,
        destinationYard: true,
        // units carry the real per-container numbers entered at booking time —
        // the wagon plan shows those instead of generated placeholders.
        // containerType.wagonTypes + cargoType.wagonTypes drive wagon-type
        // resolution during scheduling (many-to-many lists — the plan mixes
        // wagon types within one consist).
        bookingContainers: { containerType: { wagonTypes: true }, units: true },
        cargoType: { wagonTypes: true },
      },
      order: { priorityScore: 'DESC', createdAt: 'ASC' },
    });
  }

  async updateSchedulingFields(
    bookingId: string,
    fields: Partial<
      Pick<
        Booking,
        | 'schedulingStatus'
        | 'wagonsRequired'
        | 'scheduledAt'
        | 'holdStartedAt'
        | 'holdExpiresAt'
        | 'trainScheduleId'
      >
    >,
    manager?: EntityManager,
  ): Promise<void> {
    await this.bookingRepo(manager).update(bookingId, fields as never);
  }

  async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise<void> {
    const now = new Date();
    const expires = new Date(now.getTime() + 3 * 60 * 60 * 1000);
    await this.updateSchedulingFields(
      bookingId,
      {
        schedulingStatus: SchedulingStatus.Holding,
        holdStartedAt: now,
        holdExpiresAt: expires,
      },
      manager,
    );
  }
}
