import { Injectable, Logger, forwardRef, Inject } from '@nestjs/common';
import { DataSource, IsNull, Not } from 'typeorm';

import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { Contract } from '../contracts/entities/contract.entity';
import {
  ContractBookingService,
  SplitOutstanding,
} from '../contracts/contract-booking.service';
import { ContractsRepository } from '../contracts/contracts.repository';
import {
  CreateBookingUnderContractDto,
  CreateContainerUnitDto,
} from '../contracts/dto/create-booking-under-contract.dto';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingBatchService } from './booking-batch.service';
import { BookingNotifierService } from './booking-notifier.service';
import { eatDay } from './batch-window.util';

/**
 * Auto-creates and places the OUTSTANDING split remainder of a contract as a new
 * booking, so the customer doesn't have to manually rebook the wagons that did
 * not fit the train they just paid for.
 *
 * Fired (feature-flagged) right after `applySplit` runs on payment — i.e. only
 * once the customer has actually accepted+paid the offered part. Before payment
 * nothing is split: the booking stays whole and the customer may still edit or
 * cancel it. See the split lifecycle in {@link BookingSplitService.applySplit}.
 *
 * IMPORT/DOMESTIC: the remainder booking is created with the next fitting
 * shipment day set and then follows the normal windowed batch flow (train
 * assigned at window close, paid in its own window). It is NOT force-reserved on
 * a specific train — import is not FCFS.
 *
 * Container reconstruction is HYBRID: the remainder's quantities come from the
 * split snapshot (`splitOutstanding`), but the actual container numbers / VGM /
 * seals are read back from the units `applySplit` SOFT-DELETED off the parent
 * (they survive as valid ISO records). We never `restore()` those rows — the new
 * booking gets fresh rows — so the contract cap is never double-counted.
 */
@Injectable()
export class RemainderPlacementService {
  private readonly logger = new Logger(RemainderPlacementService.name);

  constructor(
    private readonly dataSource: DataSource,
    private readonly contractsRepository: ContractsRepository,
    @Inject(forwardRef(() => ContractBookingService))
    private readonly contractBookingService: ContractBookingService,
    @Inject(forwardRef(() => BookingBatchService))
    private readonly bookingBatchService: BookingBatchService,
    private readonly notifier: BookingNotifierService,
  ) {}

  /**
   * Create + place the outstanding split remainder of the contract that owns
   * `splitBooking`. No-op when there is no live remainder or no fitting day.
   * Returns the created remainder booking id, or null when nothing was placed
   * (residual falls back to the customer's manual rebook, as today).
   */
  async placeRemainder(splitBooking: Booking): Promise<string | null> {
    if (!splitBooking.contractId) return null;
    // Two payment webhooks for the same contract landing together would both see
    // the remainder as unbooked (the placing create has not committed yet) and
    // each create one — double-booking the leftover. Serialize per contract: the
    // second caller returns immediately and the first one's create is what the
    // (now smaller) outstanding reflects.
    if (this.inFlight.has(splitBooking.contractId)) {
      this.logger.debug(
        `Remainder placement already running for contract ${splitBooking.contractId} — skipped.`,
      );
      return null;
    }
    this.inFlight.add(splitBooking.contractId);
    try {
      return await this.placeRemainderInner(splitBooking);
    } finally {
      this.inFlight.delete(splitBooking.contractId);
    }
  }

  /** Contracts with a placement in flight — see {@link placeRemainder}. */
  private readonly inFlight = new Set<string>();

  private async placeRemainderInner(
    splitBooking: Booking,
  ): Promise<string | null> {
    const contract = await this.contractsRepository.findByIdWithRelations(
      splitBooking.contractId!,
    );
    if (!contract) return null;

    // ONE_TIME only. `splitOutstanding` subtracts a CONTRACT-WIDE booked total
    // from a SINGLE booking's pre-split snapshot, which is only coherent when
    // the contract has exactly one live chain — that is the ONE_TIME invariant
    // (enforced by hasSplitBooking → assertExactRemainder). On a GENERAL
    // contract with other live bookings the subtraction mixes scopes: it either
    // clamps to 0 and silently drops a real remainder, or sizes one that then
    // draws the quantity cap a second time. GENERAL remainders keep the existing
    // manual-rebook behaviour until the remainder can be derived from the
    // offer's own dropped lines rather than from the contract-wide ledger.
    if (contract.contractKind !== 'ONE_TIME') {
      this.logger.debug(
        `Contract ${contract.id} is ${contract.contractKind} — remainder left ` +
          `for manual rebook (auto-placement is ONE_TIME only).`,
      );
      return null;
    }

    const outstanding = await this.contractBookingService.splitOutstanding(
      contract,
    );
    if (!outstanding || !this.hasOutstanding(contract, outstanding)) {
      return null;
    }

    // The next fitting day: the earliest day on/after the split booking's own day
    // that still has an import train with room for this cargo type. We reuse the
    // split booking as the capacity probe — it carries the leg + cargo relations.
    const day = await this.nextFittingDay(splitBooking);
    if (!day) {
      this.logger.warn(
        `No train with room for the remainder of contract ${contract.id} ` +
          `(booking ${splitBooking.reference}) — left for manual rebook.`,
      );
      return null;
    }

    let dto: CreateBookingUnderContractDto;
    try {
      dto = await this.buildRemainderDto(
        contract,
        outstanding,
        splitBooking.id,
        day,
      );
    } catch (err) {
      // A reconstruction shortfall (fewer recoverable units than outstanding)
      // must NOT fabricate container numbers — fail loudly, leave manual rebook.
      this.logger.error(
        `Could not reconstruct the remainder of contract ${contract.id}: ` +
          `${err instanceof Error ? err.message : String(err)} — left for manual rebook.`,
      );
      return null;
    }

    // Any create-gate rejection (no train space, cap, container clash) must not
    // escape: the customer's paid booking has already boarded, and a thrown
    // error here would only be logged upstream while the remainder vanished
    // silently. Fall back to leaving it rebookable, which is the pre-feature
    // behaviour, and say so in the log.
    let created: Awaited<
      ReturnType<ContractBookingService['createUnderContract']>
    >;
    try {
      created = await this.contractBookingService.createUnderContract(
        contract.id,
        dto,
        { id: splitBooking.createdByUserId ?? undefined },
        // System actor: a permission-bag carrying the contract create-booking key
        // so the GL gate (isGlActor → hasFreightPermission) passes for GL Path B
        // contracts; harmless for customer (Path A) contracts.
        { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
      );
    } catch (err) {
      this.logger.error(
        `Could not create the remainder booking for contract ${contract.id} ` +
          `(from ${splitBooking.reference}): ${
            err instanceof Error ? err.message : String(err)
          } — left for manual rebook.`,
      );
      return null;
    }
    // EXPORT is FCFS — there is no window to wait for, so the remainder is
    // reserved on the next export train right away (its own pay window opens).
    // If it does not fit one train whole either, the export accept offers it a
    // partial and the chain repeats on ITS payment: each pass leaves a strictly
    // smaller remainder, so it terminates at the day's train count.
    // IMPORT/DOMESTIC deliberately does NOT force a train: it carries the next
    // fitting day and rides the normal windowed batch flow.
    if (splitBooking.tradeDirection === 'EXPORT') {
      const fresh = await this.dataSource
        .getRepository(Booking)
        .findOne({
          where: { id: created.booking.id },
          relations: {
            company: true,
            bookingContainers: { containerType: true },
            cargoType: true,
          },
        });
      if (fresh) {
        await this.bookingBatchService
          .acceptExportBooking(fresh)
          .catch((err) =>
            // No export train took it — it stays created and rebookable, which
            // is the same place a customer-driven rebook would leave it.
            this.logger.warn(
              `Export remainder ${fresh.reference} created but not reserved: ${
                err instanceof Error ? err.message : String(err)
              }`,
            ),
          );
      }
    }

    this.notifier.remainderPlaced(
      created.booking,
      splitBooking.reference ?? splitBooking.id,
    );
    this.logger.log(
      `Auto-placed split remainder of contract ${contract.id} as booking ` +
        `${created.booking.reference} on ${day}.`,
    );
    return created.booking.id;
  }

  private hasOutstanding(
    contract: Contract,
    outstanding: SplitOutstanding,
  ): boolean {
    if (contract.freightType === 'CONTAINER') {
      return [...outstanding.bySize.values()].some((s) => s.outstanding > 0);
    }
    return (outstanding.bulk?.outstanding ?? 0) > 0.001;
  }

  /**
   * The shipment day to create the remainder on — the split booking's own day.
   *
   * EXPORT is FCFS and must actually board a train that day, so a day with NO
   * export train having room is rejected (null → left for manual rebook on a day
   * the customer picks). IMPORT/DOMESTIC keeps the day regardless: its train is
   * assigned by the batch engine at window close, not now, and the window may
   * still free up — forcing a different day here would override the customer's
   * binding shipment day.
   */
  private async nextFittingDay(booking: Booking): Promise<string | null> {
    if (!booking.scheduledDate) return null;
    const day = eatDay(new Date(booking.scheduledDate));
    if (booking.tradeDirection !== 'EXPORT') return day;

    const fitting = await this.bookingBatchService.fittingTrainsForDay(
      booking,
      day,
      'EXPORT',
    );
    return fitting.length > 0 ? day : null;
  }

  /**
   * Build the create-DTO for the WHOLE outstanding remainder. Bulk uses the
   * outstanding tonnage directly. Container reads the deferred (soft-deleted)
   * units of the split booking back into real unit records.
   */
  private async buildRemainderDto(
    contract: Contract,
    outstanding: SplitOutstanding,
    splitBookingId: string,
    day: string,
  ): Promise<CreateBookingUnderContractDto> {
    const dto: CreateBookingUnderContractDto = { scheduledDate: day };

    if (contract.freightType !== 'CONTAINER') {
      const tons = outstanding.bulk?.outstanding ?? 0;
      dto.bulkLines = [{ cargoWeightTons: tons }];
      return dto;
    }

    // Container: recover the deferred units per size from the split booking's
    // soft-deleted rows and reshape into DTO units.
    const containers: NonNullable<CreateBookingUnderContractDto['containers']> = [];
    for (const [size, { outstanding: need }] of outstanding.bySize) {
      if (need <= 0) continue;
      const units = await this.recoverDeferredUnits(splitBookingId, size, need);
      if (units.length < need) {
        throw new Error(
          `size ${size}: recovered ${units.length} deferred container(s) but ` +
            `${need} are outstanding`,
        );
      }
      const line: NonNullable<CreateBookingUnderContractDto['containers']>[number] = {
        containerSize: size,
        quantity: need,
        units,
      };
      line.hazardousQuantity = units.filter((u) => u.isHazardous).length;
      line.reeferQuantity = units.filter((u) => u.isReefer).length;
      containers.push(line);
    }
    dto.containers = containers;
    return dto;
  }

  /**
   * The `need` deferred container units of a given size for the split booking,
   * read from the SOFT-DELETED unit rows (oldest sortOrder first — mirroring the
   * LIFO trim in applySplit so the same physical containers deferred are the
   * ones rebooked). Returns them as DTO units; does NOT restore the rows.
   */
  private async recoverDeferredUnits(
    splitBookingId: string,
    containerSize: string,
    need: number,
  ): Promise<CreateContainerUnitDto[]> {
    // The line ids of this booking for this size (live + soft-deleted): units
    // key on bookingContainerId, so gather every line of the size first.
    const lines = await this.dataSource
      .getRepository(BookingContainer)
      .find({
        where: { bookingId: splitBookingId, containerSize },
        withDeleted: true,
        select: { id: true },
      });
    const lineIds = lines.map((l) => l.id);
    if (!lineIds.length) return [];

    // Only the DELETED units are the deferred ones (live units stayed on the
    // paid part). Oldest-first to match the deferred set.
    const deferred = await this.dataSource
      .getRepository(BookingContainerUnit)
      .find({
        where: lineIds.map((bookingContainerId) => ({
          bookingContainerId,
          deletedAt: Not(IsNull()),
        })),
        withDeleted: true,
        order: { sortOrder: 'ASC', createdAt: 'ASC' },
        take: need,
      });

    return deferred.map((u) => ({
      containerNumber: u.containerNumber,
      sealNumber: u.sealNumber ?? undefined,
      vgmTons: Number(u.vgmTons),
      isHazardous: u.isHazardous,
      isReefer: u.isReefer,
    }));
  }
}
