import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Freight } from '@edr/types';

import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { BillingService } from '../billing/billing.service';
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 {
  BookingBatchOffer,
  OfferedLine,
} from './entities/booking-batch-offer.entity';
import { BookingNotifierService } from './booking-notifier.service';

export interface SizedOffer {
  offeredWagons: number;
  totalWagons: number;
  offeredLines: OfferedLine[] | null;
  offeredWeightTons: number;
  offeredAmount: number;
  offeredPricingBreakdown: Record<string, unknown>;
}

/**
 * Partial-capacity booking splits (import batch). The offer is sized and priced
 * against an in-memory clone — the booking row is untouched until the customer
 * pays, which is the act of accepting the split (applySplit). No payment →
 * offer expires and the booking stays whole.
 *
 * GENERAL and ONE_TIME commercial bookings are offered partials: the remainder
 * returns to the contract's quantity cap (derived live from booking_container
 * rows, so reducing the lines releases it automatically) and can be rebooked in
 * any later window within contract validity. The reduced booking is flagged
 * is_split (see applySplit); the contract kind never changes. On a ONE_TIME
 * contract a split booking releases the single-active-booking slot, but the
 * next booking must take the WHOLE remainder — the split chain is the only way
 * a ONE_TIME contract produces multiple bookings. Once the remainder is
 * rebooked and the cap hits zero, ContractBookingService completes the
 * contract (CONTRACT_CLOSED): no further bookings or shipment requests, even
 * while validity and a booking window are still open.
 */
@Injectable()
export class BookingSplitService {
  private readonly logger = new Logger(BookingSplitService.name);

  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
    @Inject(forwardRef(() => BookingPricingService))
    private readonly pricing: BookingPricingService,
    @Inject(forwardRef(() => BookingInvoiceService))
    private readonly invoiceService: BookingInvoiceService,
    private readonly billing: BillingService,
    private readonly notifier: BookingNotifierService,
  ) {}

  /**
   * Size the largest part of the booking that fits `freeWagons`, priced via an
   * in-memory clone. Returns null when nothing meaningful fits (no whole
   * container unit / no bulk tonnage, or pricing failed).
   *
   * `maxOfferedWeightTons` caps the offered CARGO tonnage (bulk only) — on a
   * weight-limited train the wagons' own tare eats into the locomotive's
   * remaining pull weight, so the caller passes the room left after tare.
   */
  async sizeOffer(
    booking: Booking,
    freeWagons: number,
    totalWagons: number,
    bulkWagonCapacityTons: number,
    maxOfferedWeightTons?: number,
  ): Promise<SizedOffer | null> {
    if (freeWagons < 1 || freeWagons >= totalWagons) return null;

    const containers = booking.bookingContainers ?? [];
    let offeredLines: OfferedLine[] | null = null;
    let offeredWeightTons = 0;
    let offeredWagons = 0;
    const clone: Booking = Object.assign(Object.create(Object.getPrototypeOf(booking)), booking);
    clone.adjustedTotalAmount = null;

    if (containers.length) {
      offeredLines = [];
      let remaining = freeWagons;
      const clonedContainers: BookingContainer[] = [];
      for (const line of containers) {
        const quantity = Number(line.quantity ?? 0);
        const lineWagons = Number(line.wagonsRequired ?? 0);
        if (quantity <= 0 || lineWagons <= 0 || remaining <= 0) continue;
        const perUnit = lineWagons / quantity;
        // Largest unit count whose wagon need still fits the remaining budget.
        let take = Math.min(quantity, Math.floor(remaining / perUnit));
        while (take > 0 && Math.ceil(take * perUnit) > remaining) take -= 1;
        if (take <= 0) continue;
        const takeWagons = Math.ceil(take * perUnit);
        const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0);
        offeredLines.push({
          bookingContainerId: line.id,
          quantity: take,
          wagonsRequired: takeWagons,
          totalVgmTons: Math.round(take * vgmPerUnit * 1000) / 1000,
        });
        offeredWeightTons += take * vgmPerUnit;
        offeredWagons += takeWagons;
        remaining -= takeWagons;

        const clonedLine: BookingContainer = Object.assign(
          Object.create(Object.getPrototypeOf(line)),
          line,
          {
            quantity: take,
            wagonsRequired: takeWagons,
            totalVgmTons: take * vgmPerUnit,
          },
        );
        clonedContainers.push(clonedLine);
      }
      if (!offeredLines.length || offeredWagons <= 0) return null;
      clone.bookingContainers = clonedContainers;
    } else {
      // Bulk: split by weight — the offered part is what freeWagons can carry,
      // further capped by the caller's weight room when the pull limit binds.
      const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0);
      if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null;
      offeredWeightTons = Math.min(
        totalWeight,
        freeWagons * bulkWagonCapacityTons,
        maxOfferedWeightTons ?? Number.POSITIVE_INFINITY,
      );
      if (offeredWeightTons <= 0) return null;
      offeredWagons = Math.min(
        freeWagons,
        Math.max(1, Math.ceil(offeredWeightTons / bulkWagonCapacityTons)),
      );
    }

    offeredWeightTons = Math.round(offeredWeightTons * 1000) / 1000;
    clone.cargoTotalWeightVgm = offeredWeightTons;
    clone.wagonsRequired = offeredWagons;

    try {
      const priced = await this.pricing.computePriceForBooking(clone);
      return {
        offeredWagons,
        totalWagons,
        offeredLines,
        offeredWeightTons,
        offeredAmount: priced.totalAmount,
        offeredPricingBreakdown: {
          lineItems: priced.lineItems,
          totalAmount: priced.totalAmount,
          currency: priced.currency,
          generatedAt: new Date().toISOString(),
          partialOfWagons: totalWagons,
        },
      };
    } catch (err) {
      this.logger.warn(
        `Partial pricing failed for ${booking.reference ?? booking.id}: ${(err as Error).message}`,
      );
      return null;
    }
  }

  /**
   * Persist the offer and swap the booking's payable to a partial invoice for the
   * offered amount. Any previous open offer for the booking is superseded.
   */
  async createOffer(
    booking: Booking,
    scheduleId: string,
    sized: SizedOffer,
    deadline: Date,
  ): Promise<BookingBatchOffer> {
    const repo = this.dataSource.getRepository(BookingBatchOffer);
    await repo.update({ bookingId: booking.id, status: 'OFFERED' }, { status: 'EXPIRED' });

    // The full-amount invoice must not stay payable next to the partial one.
    await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, 'PREPAID');
    const invoice = await this.invoiceService.ensureInvoiceForBooking(
      { ...booking, pricingBreakdown: sized.offeredPricingBreakdown, adjustedTotalAmount: null } as Booking,
      { dueDate: deadline, invoiceStatus: Freight.InvoiceStatus.Pending },
    );

    const offer = await repo.save(
      repo.create({
        bookingId: booking.id,
        trainScheduleId: scheduleId,
        offeredWagons: sized.offeredWagons,
        totalWagons: sized.totalWagons,
        offeredLines: sized.offeredLines,
        offeredWeightTons: sized.offeredWeightTons,
        offeredAmount: sized.offeredAmount,
        offeredPricingBreakdown: sized.offeredPricingBreakdown,
        invoiceId: invoice.id,
        paymentDeadline: deadline,
        status: 'OFFERED',
      }),
    );
    await this.notifier.payNowPartial(booking, deadline, sized.offeredWagons, sized.totalWagons);
    return offer;
  }

  /**
   * Payment received inside the window — the customer accepted the split.
   * Reduce the booking to the offered lines/weight; the remainder returns to the
   * contract cap automatically (bookedQuantities derives from live lines).
   * Idempotent: no OFFERED offer → no-op.
   */
  async applySplit(bookingId: string): Promise<void> {
    const offer = await this.dataSource.getRepository(BookingBatchOffer).findOne({
      where: { bookingId, status: 'OFFERED' },
      order: { createdAt: 'DESC' },
    });
    if (!offer) return;

    await this.dataSource.transaction(async (manager) => {
      // Snapshot what the booking carried BEFORE the reduction: on a ONE_TIME
      // contract this is the ledger the outstanding remainder is derived from
      // (there is no contract quantity cap to fall back on).
      const preSplit = await manager.getRepository(Booking).findOne({
        where: { id: bookingId },
        select: { id: true, cargoTotalWeightVgm: true },
      });
      const preSplitQuantities: { bulkTons?: number; bySize?: Record<string, number> } = {};

      if (offer.offeredLines?.length) {
        const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
        const lines = await manager.getRepository(BookingContainer).find({
          where: { bookingId },
        });
        const bySize: Record<string, number> = {};
        for (const line of lines) {
          const size = line.containerSize ?? '';
          bySize[size] = (bySize[size] ?? 0) + Number(line.quantity ?? 0);
        }
        preSplitQuantities.bySize = bySize;
        for (const line of lines) {
          const kept = keptByLine.get(line.id);
          if (!kept) {
            await manager.getRepository(BookingContainer).softDelete(line.id);
            await manager
              .getRepository(BookingContainerUnit)
              .softDelete({ bookingContainerId: line.id });
            continue;
          }
          const dropCount = Number(line.quantity) - kept.quantity;
          await manager.getRepository(BookingContainer).update(line.id, {
            quantity: kept.quantity,
            wagonsRequired: kept.wagonsRequired,
            totalVgmTons: kept.totalVgmTons,
            hazardousQuantity: Math.min(Number(line.hazardousQuantity ?? 0), kept.quantity),
            reeferQuantity: Math.min(Number(line.reeferQuantity ?? 0), kept.quantity),
          });
          if (dropCount > 0) {
            // Trim surplus physical units, last-entered first.
            const units = await manager.getRepository(BookingContainerUnit).find({
              where: { bookingContainerId: line.id },
              order: { sortOrder: 'DESC', createdAt: 'DESC' },
              take: dropCount,
            });
            if (units.length) {
              await manager
                .getRepository(BookingContainerUnit)
                .softDelete(units.map((u) => u.id));
            }
          }
        }
      } else {
        preSplitQuantities.bulkTons = Number(preSplit?.cargoTotalWeightVgm ?? 0);
      }

      // is_split releases the ONE_TIME single-active-booking slot for the
      // remainder (whole-remainder-only, enforced at booking creation) and
      // switches the contract into remainder-based completion. The contract
      // kind is NOT changed: a ONE_TIME contract stays ONE_TIME through the
      // split chain.
      await manager.getRepository(Booking).update(bookingId, {
        wagonsRequired: offer.offeredWagons,
        cargoTotalWeightVgm: offer.offeredWeightTons,
        totalAmount: offer.offeredAmount,
        pricingBreakdown: offer.offeredPricingBreakdown,
        isSplit: true,
        preSplitQuantities,
      } as never);

      await manager
        .getRepository(BookingBatchOffer)
        .update(offer.id, { status: 'APPLIED' });
    });
    this.logger.log(
      `Split applied for booking ${bookingId}: ${offer.offeredWagons}/${offer.totalWagons} wagons ride schedule ${offer.trainScheduleId}`,
    );
  }

  /** Pay window closed without payment — offer dies, booking stays whole. */
  async expireOpenOffer(bookingId: string): Promise<void> {
    await this.dataSource
      .getRepository(BookingBatchOffer)
      .update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' });
  }

  async findOpenOffer(bookingId: string): Promise<BookingBatchOffer | null> {
    return this.dataSource.getRepository(BookingBatchOffer).findOne({
      where: { bookingId, status: 'OFFERED' },
      order: { createdAt: 'DESC' },
    });
  }
}
