import {
  BadRequestException,
  forwardRef,
  Inject,
  Injectable,
  Logger,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { Freight } from "@edr/types";
import { DataSource, EntityManager } from "typeorm";

import {
  BillingService,
  GenerateInvoiceInput,
  InvoiceEventPayload,
  InvoiceLineInput,
} from "../billing/billing.service";
import { Invoice } from "../billing/entities/invoice.entity";
import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
import { BookingsRepository } from "./bookings.repository";
import { Booking } from "./entities/booking.entity";

/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
interface StoredPricingBreakdown {
  lineItems?: PriceLineItemDto[];
  totalAmount?: number;
  currency?: string;
}

export interface InvoiceOptions {
  dueDate?: Date;
  invoiceType?: string;
  invoiceStatus?: Freight.InvoiceStatus;
}

/** Round to 2 decimals, avoiding binary float drift. */
const round2 = (n: number): number => Math.round(n * 100) / 100;

/**
 * Owns the booking ⇄ invoice mapping — the one place that knows how a booking
 * turns into invoices, which type to use, and how it advances when paid. Bookings
 * are the billable business entity, so they generate their own invoices directly
 * via {@link BillingService} (billing stays source-agnostic). All booking-specific
 * type branching lives here, at the two points it belongs: invoice creation and
 * settlement (the paid handler).
 */
@Injectable()
export class BookingInvoiceService {
  private readonly logger = new Logger(BookingInvoiceService.name);

  constructor(
    private readonly billing: BillingService,
    private readonly bookingsRepository: BookingsRepository,
    private readonly dataSource: DataSource,
    @Inject(forwardRef(() => FirstMileService))
    private readonly firstMile: FirstMileService,
    @Inject(forwardRef(() => BookingBatchService))
    private readonly bookingBatch: BookingBatchService,
  ) { }

  /**
   * Ensure the booking has its invoice, generating one from the snapshotted
   * pricing breakdown if absent. Called when a booking reaches a billable state.
   * Idempotent — returns the existing open invoice instead of a duplicate.
   * Throws `BadRequestException` when the booking is not billable: no company
   * to bill (e.g. government bookings whose `companyId` is null, which the
   * invoices FK requires), or no priced amount.
   */
  async ensureInvoiceForBooking(
    booking: Booking,
    invoiceOptions: InvoiceOptions = {},
  ): Promise<Invoice> {
    const existing = await this.billing.findPayable(
      Freight.InvoiceSource.Booking,
      booking.id,
      "PREPAID",
    );
    if (existing) return existing;

    if (!booking.companyId) {
      throw new BadRequestException(
        `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
      );
    }

    const input = this.buildInput(booking, invoiceOptions);

    return this.billing.generateInvoice(input);
  }

  /**
   * React to a booking invoice being paid — the settlement branch point. Per-type
   * reactions live here (not in the payment process): each invoice type advances
   * the booking its own way. Only PREPAID exists today.
   */
  @OnEvent("booking.invoice.paid")
  async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
    this.logger.log(
      `onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`,
    );
    switch (payload.type) {
      case "PREPAID":
        await this.advanceBookingOnPayment(payload.sourceId);
        break;
      default:
        this.logger.warn(
          `Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
        );
    }
  }

  updateStatus(
    invoiceId: string,
    status: Freight.InvoiceStatus,
    manager?: EntityManager,
  ): Promise<void> {
    return this.billing.updateStatus(invoiceId, status, manager);
  }

  /**
   * Expire the booking's currently-open freight (PREPAID) invoice when the booking is
   * cancelled or rejected — the counterpart to the pay-window-expiry path
   * (which also calls {@link BillingService.expirePayable}). Stops a terminated
   * booking from leaving a payable invoice open. No-op when the booking has no
   * open invoice (never invoiced, already paid/cancelled/expired). Pass a
   * caller `manager` to enlist in its transaction.
   */
  async expireOpenInvoices(
    bookingId: string,
    manager?: EntityManager,
  ): Promise<Invoice | null> {
    return this.billing.expirePayable(
      Freight.InvoiceSource.Booking,
      bookingId,
      "PREPAID",
      manager,
    );
  }

  /**
   * Advance a booking once its prepaid invoice settles — the domain side-effect
   * of payment, relocated out of the payment service: the booking becomes PAID
   * and is allocated into its batch. Idempotent — no-op when already PAID.
   *
   * General contracts are a separate aggregate now: their CONTRACT_ACTIVE
   * lifecycle and ordering window live in the contracts module, advanced by the
   * contract transition/clearance services — not by booking payment. Every
   * booking that settles here is a ONE_TIME shipment, so there is no contract
   * branch (legacy GENERAL_CONTRACT booking creation now 410s).
   */
  private async advanceBookingOnPayment(bookingId: string): Promise<void> {
    const booking = await this.bookingsRepository.findById(bookingId);
    if (!booking) {
      this.logger.warn(
        `Cannot advance unknown booking ${bookingId} on payment.`,
      );
      return;
    }

    // Idempotency + state-machine guard (restored). The prepaid-invoice paid
    // event can be delivered more than once (retries / re-emit), and a booking
    // may have moved on or been terminated between invoicing and settlement.
    // Only advance one that is still awaiting payment: no-op when already PAID,
    // and refuse to advance a booking in a terminal/advanced status
    // (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never
    // rewrite its status or re-run allocation.
    if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
      return;
    }
    const TERMINAL_OR_ADVANCED_STATUSES: string[] = [
      "CANCELLED",
      "REJECTED",
      "EXPIRED",
      "IN_TRANSIT",
      "ARRIVED",
      "COMPLETED",
      "CONTRACT_CLOSED",
    ];
    if (TERMINAL_OR_ADVANCED_STATUSES.includes(booking.status)) {
      this.logger.warn(
        `Skipping advance of booking ${bookingId} on payment: status ${booking.status} is terminal/advanced.`,
      );
      return;
    }

    await this.dataSource.transaction(async (mg) => {
      await mg.update(
        Booking,
        { id: bookingId },
        { paymentStatus: "PAID", status: "PAID" },
      );
    });

    try {
      await this.firstMile.acceptBooking(bookingId);
    } catch (err) {
      this.logger.error(
        `Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`,
      );
    }

    try {
      await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
    } catch (err) {
      this.logger.error(
        `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
      );
    }
  }

  /** Map a booking's pricing snapshot into a generic invoice request. */
  private buildInput(
    booking: Booking,
    invoiceOptions: InvoiceOptions = {},
  ): GenerateInvoiceInput {
    const breakdown = (booking.pricingBreakdown ??
    {}) as StoredPricingBreakdown;
    const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB";

    const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
      chargeType: l.code,
      description: l.description,
      quantity: l.quantity,
      unitRate: l.unitAmount,
      amount: l.amount,
      currency: l.currency ?? currency,
      metadata: l.unit ? { unit: l.unit } : null,
    }));

    // Fall back to a single freight line when no breakdown was snapshotted.
    if (lines.length === 0) {
      const amount = Number(booking.totalAmount);
      if (!Number.isFinite(amount) || amount <= 0) {
        throw new BadRequestException(
          `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
        );
      }
      lines.push({
        chargeType: "FREIGHT",
        description: "Rail freight",
        quantity: 1,
        unitRate: amount,
        amount,
        currency,
      });
    }

    const subtotal = round2(
      lines.reduce((sum, l) => sum + Number(l.amount), 0),
    );
    let totalAmount = subtotal;

    // Honor a staff price override: bill the adjusted total, recording the delta
    // as an ADJUSTMENT line so the lines still sum to the invoice total.
    const adjusted = booking.adjustedTotalAmount;
    if (adjusted != null && Number.isFinite(Number(adjusted))) {
      const delta = round2(Number(adjusted) - subtotal);
      if (delta !== 0) {
        lines.push({
          chargeType: "ADJUSTMENT",
          description: "Staff price adjustment",
          quantity: 1,
          unitRate: delta,
          amount: delta,
          currency,
        });
      }
      totalAmount = round2(Number(adjusted));
    }

    return {
      source: Freight.InvoiceSource.Booking,
      sourceId: booking.id,
      companyId: booking.companyId,
      companyProfileId: booking.companyProfileId,
      currency,
      lines,
      totalAmount,
      dueAt: invoiceOptions.dueDate,
      type: invoiceOptions.invoiceType ?? "PREPAID",
      status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft,
    };
  }
}
