import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { Freight, NotificationAudience, NotificationType } from "@edr/types";
import { DataSource } from "typeorm";

import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";

import {
  BillingService,
  InvoiceEventPayload,
  InvoiceLineInput,
} from "../billing/billing.service";
import { Invoice } from "../billing/entities/invoice.entity";
import { InvoiceLine } from "../billing/entities/invoice-line.entity";

import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
import {
  InvoiceDocumentModel,
  InvoiceDocumentService,
} from "../billing/documents/invoice-document.service";
import { NotificationsService } from "../notifications/notifications.service";
import {
  companyNotifyPhoneExpr,
  primaryContactUserJoin,
} from "../notifications/resolve-company-phone.util";
import { WarehouseFeeService } from "./warehouse-fee.service";
import {
  WarehouseFeeInvoiceView,
  WarehouseFeeType,
  WarehouseInvoiceItemView,
  WarehouseInvoiceStatus,
  WarehouseInvoiceType,
} from "./warehouse-invoice.types";

interface GenerateOptions {
  confirmZero?: boolean;
  performedBy?: string;
  billingCurrency?: "ETB" | "USD";
}

export interface PayInvoiceDto {
  amount: number;
  method?: string;
  reference?: string;
  driverName?: string;
  driverPhone?: string;
}

/** Warehouse fee invoices live in the global billing system under this source. */
const SOURCE = Freight.InvoiceSource.Warehouse;

/** Global statuses that still owe money and therefore block terminal release. */
const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
  Freight.InvoiceStatus.Issued,
  Freight.InvoiceStatus.Pending,
  Freight.InvoiceStatus.PartiallyPaid,
  Freight.InvoiceStatus.Overdue,
];
/** Global statuses considered an "active" invoice for per-inventory dedup. */
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
  ...BLOCKING_STATUSES,
  Freight.InvoiceStatus.Paid,
];

export interface InvoiceDocumentDetails {
  bookingReference: string | null;
  customerName: string | null;
  inventoryReference: string | null;
  inventoryInfo: string | null;
  inventoryStatus: string | null;
  containerNumber: string | null;
  cargoDescription: string | null;
  clearanceStatus: string;
  warehouseName: string | null;
  yardName: string | null;
  zoneName: string | null;
}

export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView &
  Partial<InvoiceDocumentDetails> & { items: WarehouseInvoiceItemView[] };

/** The warehouse-specific columns derived from the linked inventory item. */
interface InventoryContext {
  bookingId: string | null;
  facilityId: string | null;
  warehouseId: string | null;
  yardId: string | null;
  zoneId: string | null;
  periodStart: Date | null;
}

/** Source fields a view is projected from — satisfied by the global {@link Invoice}. */
interface ViewSource {
  id: string;
  invoiceNumber: string;
  companyId: string;
  sourceId: string;
  type: string;
  status: Freight.InvoiceStatus | string;
  subtotalAmount: number | string;
  taxAmount: number | string;
  totalAmount: number | string;
  paidAmount: number | string;
  balanceAmount: number | string;
  currency: string;
  issuedAt?: Date | null;
  dueAt?: Date | null;
  paidAt?: Date | null;
  createdAt: Date;
  updatedAt: Date;
  payments?: Array<{
    amount: number | string;
    method?: string | null;
    reference?: string | null;
    paidAt: string;
  }> | null;
}

/**
 * Thin warehouse layer over the central {@link BillingService}. Warehouse fee
 * invoices are global `Invoice` rows (`source = warehouse`, `sourceId =
 * inventoryId`); this service owns only the warehouse-specific concerns —
 * computing fees, per-inventory dedup, release-blocking, SMS notifications, the
 * sealed PDF, and reshaping the global invoice back into the historical
 * `WarehouseFeeInvoice` JSON the portal/backoffice expect. All money, numbering,
 * status, and payment math live in billing.
 */
@Injectable()
export class WarehouseInvoiceService {
  private readonly logger = new Logger(WarehouseInvoiceService.name);

  constructor(
    private readonly dataSource: DataSource,
    private readonly billing: BillingService,
    private readonly invoiceDocuments: InvoiceDocumentService,
    private readonly feeService: WarehouseFeeService,
    private readonly notifications: NotificationsService,
    private readonly inbox: NotificationInboxService,
  ) { }

  // ── Generation ───────────────────────────────────────────────────────────
  async generateForInventory(
    inventoryId: string,
    opts: GenerateOptions = {},
  ): Promise<WarehouseFeeInvoiceDetail> {
    const [item] = await this.dataSource.query(
      `SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
              inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
              w.facility_id AS "facilityId",
              b.company_id AS "companyId", b.company_profile_id AS "companyProfileId",
              b.freight_type AS "freightType"
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
         LEFT JOIN freight.bookings b ON b.id = inv.booking_id
        WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
      [inventoryId],
    );
    if (!item)
      throw new NotFoundException(`Inventory item ${inventoryId} not found`);

    // Routing through the global invoice requires a billable company + profile,
    // both of which come from the inventory's booking.
    if (!item.companyId || !item.companyProfileId) {
      throw new BadRequestException(
        "Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).",
      );
    }

    // Dedup: only one active (non-cancelled) invoice per inventory item.
    if (await this.hasActiveInvoice(inventoryId)) {
      throw new ConflictException(
        "An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.",
      );
    }

    const billingCurrency = opts.billingCurrency === "ETB" ? "ETB" : "USD";
    const previews = await this.feeService.previewForInventory(
      inventoryId,
      billingCurrency,
    );
    const isContainer = (item.freightType ?? "").toUpperCase() === "CONTAINER";

    const items = previews
      .filter((p) => p.amount > 0)
      .map((p) => {
        const days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`;
        const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`;
        let feeType: WarehouseFeeType;
        let description: string;
        switch (p.ruleType) {
          case "STORAGE_FEE":
            feeType = "STORAGE_FEE";
            description = `Storage fee - ${days}${tierSuffix}`;
            break;
          case "DOUBLE_HANDLING_FEE": {
            feeType = "DOUBLE_HANDLING";
            const unit =
              p.basis === "PER_TON"
                ? "ton(s)"
                : p.basis === "PER_ITEM"
                  ? "item(s)"
                  : "container(s)";
            description = `Double handling - ${p.billableUnits} ${unit}`;
            break;
          }
          case "TRUCK_DETENTION_FEE":
            feeType = "TRUCK_DETENTION";
            description = `Truck detention - ${days}${tierSuffix}`;
            break;
          default:
            feeType = isContainer ? "CONTAINER_DEMURRAGE" : "BULK_DEMURRAGE";
            description = `${isContainer ? "Container" : "Bulk"} demurrage - ${days}${tierSuffix}`;
        }
        return {
          feeRuleId: p.ruleId,
          feeType,
          description,
          quantity: p.billableUnits,
          unitRate: p.ratePerDay,
          amount: p.amount,
          currency: p.currency,
          chargeableDays: p.chargeableDays,
          freeDays: p.freeDays,
        };
      });

    const total = items.reduce((s, i) => s + i.amount, 0);
    if (total <= 0 && !opts.confirmZero) {
      throw new BadRequestException(
        "No payable warehouse fee found for this item.",
      );
    }

    const hasDemurrage = items.some((i) => i.feeType !== "STORAGE_FEE");
    const hasStorage = items.some((i) => i.feeType === "STORAGE_FEE");
    const invoiceType: WarehouseInvoiceType =
      hasDemurrage && hasStorage
        ? "MIXED_WAREHOUSE_FEES"
        : hasStorage
          ? "STORAGE_FEE"
          : "DEMURRAGE";

    const lines: InvoiceLineInput[] = items.map((it) => ({
      chargeType: it.feeType,
      description: it.description,
      quantity: it.quantity,
      unitRate: it.unitRate,
      amount: it.amount,
      currency: it.currency,
      metadata: {
        feeRuleId: it.feeRuleId ?? null,
        chargeableDays: it.chargeableDays ?? null,
        freeDays: it.freeDays ?? null,
      },
    }));

    const invoice = await this.billing.generateInvoice({
      source: SOURCE,
      sourceId: inventoryId,
      type: invoiceType,
      companyId: item.companyId,
      companyProfileId: item.companyProfileId,
      currency: billingCurrency,
      lines,
      status: Freight.InvoiceStatus.Issued,
    });

    const detail = await this.findById(invoice.id);
    await this.notifyWarehouseFeeIssued(detail);
    return detail;
  }

  /**
   * Generate a truck-detention invoice for a last-mile leg. Unlike warehouse fees
   * (per inventory item), detention is a per-truck charge on the last-mile leg, so
   * it becomes a `last_mile` invoice with its own `TRUCK_DETENTION_FEE` type — kept
   * separate from the delivery-fee invoice. Returns the global Invoice.
   */
  async generateTruckDetentionInvoice(
    lastMileId: string,
    opts: { billingCurrency?: "ETB" | "USD"; confirmZero?: boolean } = {},
  ): Promise<Invoice> {
    const [lm] = await this.dataSource.query(
      `SELECT lm.id,
              b.company_id         AS "companyId",
              b.company_profile_id AS "companyProfileId",
              b.payment_currency   AS "paymentCurrency"
         FROM freight.last_mile lm
         LEFT JOIN freight.bookings b ON b.id = lm.booking_id
        WHERE lm.id = $1 AND lm.deleted_at IS NULL`,
      [lastMileId],
    );
    if (!lm) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
    if (!lm.companyId) {
      throw new BadRequestException(
        "Cannot invoice truck detention: the last-mile leg has no billable company (no associated booking).",
      );
    }

    const existing = await this.billing.findPayable(
      "last_mile" as Freight.InvoiceSource,
      lastMileId,
      "TRUCK_DETENTION_FEE",
    );
    if (existing) {
      throw new ConflictException(
        "An active truck detention invoice already exists for this last-mile leg. Cancel it before generating a new one.",
      );
    }

    const billingCurrency: "ETB" | "USD" =
      opts.billingCurrency ?? (lm.paymentCurrency === "ETB" ? "ETB" : "USD");
    const preview = await this.feeService.previewTruckDetention(lastMileId, billingCurrency);
    if (preview.amount <= 0 && !opts.confirmZero) {
      throw new BadRequestException(
        "No truck detention is currently payable for this last-mile leg.",
      );
    }

    // One line per truck-type group (each billed by its own matching rule). Groups
    // with no matching rule bill 0 and are dropped. Falls back to a single line.
    const groups = preview.groups && preview.groups.length ? preview.groups : null;
    const lines: InvoiceLineInput[] = groups
      ? groups
          .filter((g) => g.amount > 0)
          .map((g) => ({
            chargeType: "TRUCK_DETENTION",
            description: `Truck detention${g.vehicleType ? ` (${g.vehicleType})` : ""} - ${g.chargeableDays} day(s) x ${g.truckCount} truck(s)`,
            quantity: g.truckCount * g.chargeableDays,
            unitRate: g.ratePerDay,
            amount: g.amount,
            currency: preview.currency,
            metadata: {
              feeRuleId: g.ruleId ?? null,
              chargeableDays: g.chargeableDays,
              vehicleType: g.vehicleType ?? null,
            },
          }))
      : [
          {
            chargeType: "TRUCK_DETENTION",
            description: `Truck detention - ${preview.chargeableDays} day(s) x ${preview.containerCount} truck(s)`,
            quantity: preview.billableUnits,
            unitRate: preview.ratePerDay,
            amount: preview.amount,
            currency: preview.currency,
            metadata: {
              feeRuleId: preview.ruleId ?? null,
              chargeableDays: preview.chargeableDays ?? null,
            },
          },
        ];
    if (lines.length === 0) {
      throw new BadRequestException(
        "No truck detention is currently payable for this last-mile leg.",
      );
    }

    return this.billing.generateInvoice({
      source: "last_mile" as Freight.InvoiceSource,
      sourceId: lastMileId,
      type: "TRUCK_DETENTION_FEE",
      companyId: lm.companyId,
      companyProfileId: lm.companyProfileId || "",
      currency: billingCurrency,
      lines,
      status: Freight.InvoiceStatus.Issued,
    });
  }

  // ── Reads ────────────────────────────────────────────────────────────────
  async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
    const invoice = await this.loadWarehouseInvoice(id);
    const ctx = await this.getInventoryContext(invoice.sourceId);
    const details = await this.getInvoiceDocumentDetails(invoice);
    const items = invoice.lines.map((l) => this.lineToItem(l));
    return { ...this.buildView(invoice, ctx), ...details, items };
  }

  listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> {
    return this.queryViews("AND i.source_id = $1", [inventoryId]);
  }

  listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> {
    return this.queryViews("AND inv.booking_id = $1", [bookingId]);
  }

  async findAll(
    filter: Partial<
      Pick<
        WarehouseFeeInvoiceView,
        | "status"
        | "invoiceType"
        | "warehouseId"
        | "facilityId"
        | "customerId"
        | "bookingId"
      >
    >,
  ): Promise<WarehouseFeeInvoiceView[]> {
    const conditions: string[] = [];
    const params: unknown[] = [];
    const add = (sql: (p: string) => string, value: unknown) => {
      params.push(value);
      conditions.push(sql(`$${params.length}`));
    };

    if (filter.status)
      add(
        (p) => `i.status::text = ${p}`,
        this.toGlobalStatus(filter.status as WarehouseInvoiceStatus),
      );
    if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType);
    if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId);
    if (filter.warehouseId)
      add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
    if (filter.facilityId)
      add((p) => `w.facility_id = ${p}`, filter.facilityId);
    if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId);

    return this.queryViews(conditions.map((c) => `AND ${c}`).join(" "), params);
  }

  async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
    const invoice = await this.findById(id);
    return this.invoiceDocuments.render(
      this.toDocumentModel(invoice, "INVOICE"),
    );
  }

  async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
    const invoice = await this.findById(id);
    if (Number(invoice.paidAmount) <= 0) {
      throw new BadRequestException(
        "A receipt is available only after payment is recorded.",
      );
    }
    return this.invoiceDocuments.render(
      this.toDocumentModel(invoice, "RECEIPT"),
    );
  }

  // ── State changes ────────────────────────────────────────────────────────
  async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> {
    const invoice = await this.loadWarehouseInvoice(id);
    if (invoice.status === Freight.InvoiceStatus.Paid) {
      throw new BadRequestException("A paid invoice cannot be cancelled.");
    }
    await this.billing.cancelInvoice(id);
    return this.findById(id);
  }

  /** Record a payment against the invoice (delegates settlement to billing). */
  async pay(
    id: string,
    dto: PayInvoiceDto,
  ): Promise<WarehouseFeeInvoiceDetail> {
    // Guard that this is a warehouse invoice before recording (404 otherwise).
    await this.loadWarehouseInvoice(id);
    await this.billing.recordPayment(id, {
      amount: dto.amount,
      method: dto.method ?? null,
      reference: dto.reference ?? null,
      metadata:
        dto.driverName || dto.driverPhone
          ? {
            driverName: dto.driverName ?? null,
            driverPhone: dto.driverPhone ?? null,
          }
          : null,
    });
    const detail = await this.findById(id);
    await this.notifyWarehouseFeePayment(detail, dto);
    return detail;
  }

  /** Initiate a wallet/gateway payment for the invoice. */
  async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) {
    const invoice = await this.loadWarehouseInvoice(id);
    if (invoice.status === Freight.InvoiceStatus.Paid) {
      throw new BadRequestException("Invoice is already fully paid.");
    }

    return this.billing.payInvoice(invoice.id, {
      method: dto.method ?? (invoice.currency === "USD" ? "WAAFI" : "TELEBIRR"),
      platform: dto.platform ?? "web",
      payerAccount: dto.payerAccount,
      returnUrl: dto.returnUrl,
      failureUrl: dto.failureUrl,
    });
  }

  /**
   * Notify on online (gateway) settlement — the domain side-effect of a warehouse
   * fee being paid through billing's payment flow. The counter {@link pay} path
   * notifies inline (and carries driver details from the request), so this only
   * handles gateway payments: those stamp the invoice `paymentId`, whereas a
   * counter settlement leaves it null. Skipping null-`paymentId` events avoids
   * double-notifying a counter payment that already sent its SMS.
   */
  @OnEvent("warehouse.invoice.paid")
  async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
    if (!payload.paymentId) return;
    const detail = await this.findById(payload.invoiceId);
    await this.notifyWarehouseFeePayment(detail, {
      amount: Number(detail.totalAmount),
    });
  }

  // ── Release blocking ──────────────────────────────────────────────────────
  /** Returns the first unpaid invoice that blocks terminal release, or null. */
  async findBlockingInvoice(
    inventoryId: string,
  ): Promise<WarehouseFeeInvoiceView | null> {
    const blocking = await this.queryViews(
      `AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
      [inventoryId, BLOCKING_STATUSES],
    );
    return blocking[0] ?? null;
  }

  async assertClearanceAllowed(inventoryId: string): Promise<void> {
    const invoices = await this.queryViews("AND i.source_id = $1", [
      inventoryId,
    ]);
    const blocking = invoices.find(
      (inv) => inv.status === "ISSUED" || inv.status === "PARTIALLY_PAID",
    );
    if (blocking) {
      throw new BadRequestException(
        `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
      );
    }

    if (invoices.some((inv) => inv.status === "PAID")) return;

    const previews = await this.feeService.previewForInventory(
      inventoryId,
      "USD",
    );
    const payableAmount = previews.reduce(
      (sum, fee) => sum + Number(fee.amount || 0),
      0,
    );
    if (payableAmount > 0) {
      throw new BadRequestException(
        "Generate and fully pay the warehouse demurrage/storage invoice before terminal release.",
      );
    }
  }

  // ── Internal: loading & projection ─────────────────────────────────────────

  /** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */
  private async loadWarehouseInvoice(
    id: string,
  ): Promise<Invoice & { lines: InvoiceLine[] }> {
    const invoice = await this.billing.findById(id);
    if (invoice.source !== SOURCE) {
      throw new NotFoundException(`Invoice ${id} not found`);
    }
    return invoice;
  }

  private async hasActiveInvoice(inventoryId: string): Promise<boolean> {
    const [row] = await this.dataSource.query(
      `SELECT 1
         FROM freight.invoices
        WHERE source = $1 AND source_id = $2 AND status::text = ANY($3::text[]) AND deleted_at IS NULL
        LIMIT 1`,
      [SOURCE, inventoryId, ACTIVE_STATUSES],
    );
    return Boolean(row);
  }

  /**
   * Project warehouse-source global invoices into the historical view, joined to
   * their inventory item for the typed FKs. Powers every list/filter read.
   */
  private async queryViews(
    extraWhere: string,
    params: unknown[],
  ): Promise<WarehouseFeeInvoiceView[]> {
    const rows = await this.dataSource.query(
      `SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId",
              i.source_id AS "sourceId", i.type, i.status,
              i.subtotal_amount AS "subtotalAmount", i.tax_amount AS "taxAmount",
              i.total_amount AS "totalAmount", i.paid_amount AS "paidAmount",
              i.balance_amount AS "balanceAmount", i.currency, i.payments,
              i.issued_at AS "issuedAt", i.due_at AS "dueAt", i.paid_at AS "paidAt",
              i.created_at AS "createdAt", i.updated_at AS "updatedAt",
              inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
              inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
              w.facility_id AS "facilityId"
         FROM freight.invoices i
         LEFT JOIN freight.warehouse_inventory inv ON inv.id::text = i.source_id AND inv.deleted_at IS NULL
         LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
        WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere}
        ORDER BY i.created_at DESC`,
      [...params, SOURCE],
    );

    return (rows as Array<ViewSource & InventoryContext>).map((row) =>
      this.buildView(row, {
        bookingId: row.bookingId ?? null,
        facilityId: row.facilityId ?? null,
        warehouseId: row.warehouseId ?? null,
        yardId: row.yardId ?? null,
        zoneId: row.zoneId ?? null,
        periodStart: row.periodStart ?? null,
      }),
    );
  }

  /** Reshape a global invoice (+ derived inventory context) into the warehouse view. */
  private buildView(
    inv: ViewSource,
    ctx: InventoryContext,
  ): WarehouseFeeInvoiceView {
    const status = this.toWarehouseStatus(inv.status);
    return {
      id: inv.id,
      invoiceNumber: inv.invoiceNumber,
      bookingId: ctx.bookingId,
      customerId: inv.companyId ?? null,
      inventoryId: inv.sourceId,
      facilityId: ctx.facilityId,
      warehouseId: ctx.warehouseId,
      yardId: ctx.yardId,
      zoneId: ctx.zoneId,
      invoiceType: inv.type as WarehouseInvoiceType,
      status,
      subtotalAmount: Number(inv.subtotalAmount),
      taxAmount: Number(inv.taxAmount),
      totalAmount: Number(inv.totalAmount),
      paidAmount: Number(inv.paidAmount),
      balanceAmount: Number(inv.balanceAmount),
      currency: inv.currency,
      periodStart: ctx.periodStart,
      // No standalone period column once centralized: the charge window ends at
      // issuance, so `issuedAt` is the period end.
      periodEnd: inv.issuedAt ?? null,
      issuedAt: inv.issuedAt ?? null,
      dueDate: inv.dueAt ?? null,
      paidAt: inv.paidAt ?? null,
      cancelledAt: status === "CANCELLED" ? inv.updatedAt : null,
      payments: (inv.payments ?? []).map((p) => ({
        amount: Number(p.amount),
        method: p.method ?? null,
        reference: p.reference ?? null,
        paidAt: p.paidAt,
      })),
      notes: null,
      createdAt: inv.createdAt,
      updatedAt: inv.updatedAt,
    };
  }

  private lineToItem(line: InvoiceLine): WarehouseInvoiceItemView {
    const meta = (line.metadata ?? {}) as {
      feeRuleId?: string | null;
      chargeableDays?: number | null;
      freeDays?: number | null;
    };
    return {
      feeRuleId: meta.feeRuleId ?? null,
      feeType: line.chargeType as WarehouseFeeType,
      description: line.description ?? "",
      quantity: Number(line.quantity),
      unitRate: Number(line.unitRate),
      amount: Number(line.amount),
      currency: line.currency,
      chargeableDays: meta.chargeableDays ?? null,
      freeDays: meta.freeDays ?? null,
    };
  }

  private toWarehouseStatus(
    status: Freight.InvoiceStatus | string,
  ): WarehouseInvoiceStatus {
    switch (status) {
      case Freight.InvoiceStatus.Draft:
        return "DRAFT";
      case Freight.InvoiceStatus.PartiallyPaid:
        return "PARTIALLY_PAID";
      case Freight.InvoiceStatus.Paid:
        return "PAID";
      case Freight.InvoiceStatus.Cancelled:
      case Freight.InvoiceStatus.Refunded:
        return "CANCELLED";
      default:
        // Issued / Pending / Overdue → an issued, still-owed invoice.
        return "ISSUED";
    }
  }

  private toGlobalStatus(
    status: WarehouseInvoiceStatus,
  ): Freight.InvoiceStatus {
    switch (status) {
      case "DRAFT":
        return Freight.InvoiceStatus.Draft;
      case "PARTIALLY_PAID":
        return Freight.InvoiceStatus.PartiallyPaid;
      case "PAID":
        return Freight.InvoiceStatus.Paid;
      case "CANCELLED":
        return Freight.InvoiceStatus.Cancelled;
      default:
        return Freight.InvoiceStatus.Issued;
    }
  }

  /** Map a warehouse fee invoice view onto the shared document model. */
  private toDocumentModel(
    invoice: WarehouseFeeInvoiceDetail,
    kind: "INVOICE" | "RECEIPT",
  ): InvoiceDocumentModel {
    const lastPayment = [...(invoice.payments ?? [])].pop();
    const date = (value: unknown) =>
      value
        ? new Date(value as string | Date).toLocaleDateString("en-GB")
        : null;

    return {
      kind,
      title: "Warehouse Fee",
      documentNumber: invoice.invoiceNumber,
      issuedAt: invoice.issuedAt ?? invoice.createdAt,
      status: invoice.status,
      currency: invoice.currency,
      summary: [
        { label: "Status", value: invoice.status.replace(/_/g, " ") },
        {
          label: "Invoice type",
          value: invoice.invoiceType.replace(/_/g, " "),
        },
        { label: "Booking reference", value: invoice.bookingReference ?? null },
        { label: "Customer", value: invoice.customerName ?? null },
        {
          label: "Inventory reference",
          value: invoice.inventoryReference ?? null,
        },
        { label: "Inventory info", value: invoice.inventoryInfo ?? null },
        { label: "Clearance", value: invoice.clearanceStatus ?? null },
        { label: "Warehouse", value: invoice.warehouseName ?? null },
        {
          label: "Yard / Zone",
          value:
            [invoice.yardName, invoice.zoneName].filter(Boolean).join(" / ") ||
            null,
        },
        {
          label: "Period",
          value: `${date(invoice.periodStart) ?? "-"} - ${date(invoice.periodEnd) ?? "-"}`,
        },
        {
          label: "Payment",
          value: lastPayment
            ? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}`
            : null,
        },
      ],
      categoryHeader: "Fee type",
      lines: invoice.items.map((item) => ({
        description: item.description ?? null,
        category: item.feeType ?? null,
        quantity: item.quantity ?? item.chargeableDays ?? 0,
        unitRate: item.unitRate,
        amount: item.amount,
        currency: item.currency ?? invoice.currency,
      })),
      totals: [
        { label: "Subtotal", amount: Number(invoice.subtotalAmount) },
        { label: "Tax", amount: Number(invoice.taxAmount) },
        { label: "Total", amount: Number(invoice.totalAmount), grand: true },
        { label: "Paid", amount: Number(invoice.paidAmount) },
        { label: "Balance", amount: Number(invoice.balanceAmount) },
      ],
    };
  }

  /** Warehouse-specific display details, derived from the linked inventory item. */
  private async getInvoiceDocumentDetails(
    invoice: ViewSource,
  ): Promise<InvoiceDocumentDetails> {
    const [row] = await this.dataSource.query(
      `SELECT b.reference AS "bookingReference",
              company.name AS "customerName",
              COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
              inv.status AS "inventoryStatus",
              inv.release_date AS "releaseDate",
              COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
              COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
              CONCAT_WS(
                ' / ',
                NULLIF(inv.status, ''),
                NULLIF(COALESCE(container.container_number, booking_container.container_number), ''),
                NULLIF(COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description), '')
              ) AS "inventoryInfo",
              wh.name AS "warehouseName",
              yard.name AS "yardName",
              zone.name AS "zoneName"
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
         LEFT JOIN freight.companies company ON company.id = b.company_id
         LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
         LEFT JOIN freight.booking_container booking_container ON (
           booking_container.booking_id = b.id
           AND booking_container.deleted_at IS NULL
         )
         LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
         LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
         LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
         LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
         LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
        WHERE inv.id = $1 AND inv.deleted_at IS NULL
        LIMIT 1`,
      [invoice.sourceId],
    );

    const fullyPaid = this.toWarehouseStatus(invoice.status) === "PAID";
    const clearanceStatus = row?.releaseDate
      ? "RELEASE ISSUED"
      : fullyPaid
        ? "FEE PAID - READY FOR RELEASE"
        : "PENDING PAYMENT";

    return {
      bookingReference: row?.bookingReference ?? null,
      customerName: row?.customerName ?? null,
      inventoryReference: row?.inventoryReference ?? null,
      inventoryInfo: row?.inventoryInfo ?? null,
      inventoryStatus: row?.inventoryStatus ?? null,
      containerNumber: row?.containerNumber ?? null,
      cargoDescription: row?.cargoDescription ?? null,
      warehouseName: row?.warehouseName ?? null,
      yardName: row?.yardName ?? null,
      zoneName: row?.zoneName ?? null,
      clearanceStatus,
    };
  }

  private async getInventoryContext(
    inventoryId: string,
  ): Promise<InventoryContext> {
    const [row] = await this.dataSource.query(
      `SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
              inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
              w.facility_id AS "facilityId"
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
        WHERE inv.id = $1 AND inv.deleted_at IS NULL
        LIMIT 1`,
      [inventoryId],
    );
    return {
      bookingId: row?.bookingId ?? null,
      facilityId: row?.facilityId ?? null,
      warehouseId: row?.warehouseId ?? null,
      yardId: row?.yardId ?? null,
      zoneId: row?.zoneId ?? null,
      periodStart: row?.periodStart ?? null,
    };
  }

  // ── Notifications ──────────────────────────────────────────────────────────
  private async getInvoiceNotificationContacts(inventoryId: string): Promise<{
    bookingReference: string | null;
    customerName: string | null;
    customerPhone: string | null;
    driverName: string | null;
    driverPhone: string | null;
    containerNumber: string | null;
    cargoDescription: string | null;
  }> {
    const [row] = await this.dataSource.query(
      `SELECT b.reference AS "bookingReference",
              company.name AS "customerName",
              ${companyNotifyPhoneExpr('company')} AS "customerPhone",
              COALESCE(
                NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''),
                last_vehicle.assigned_driver_name,
                NULLIF(TRIM(CONCAT(COALESCE(first_driver.first_name, ''), ' ', COALESCE(first_driver.last_name, ''))), ''),
                first_vehicle.assigned_driver_name
              ) AS "driverName",
              COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
              COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
              COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
         FROM freight.warehouse_inventory inv
         LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
         LEFT JOIN freight.companies company ON company.id = b.company_id
         ${primaryContactUserJoin('company')}
         LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
         LEFT JOIN freight.booking_container booking_container ON (
           booking_container.booking_id = b.id
           AND booking_container.deleted_at IS NULL
         )
         LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
         LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
         LEFT JOIN LATERAL (
           SELECT lm.vehicle_id
             FROM freight.last_mile lm
            WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL
            ORDER BY lm.created_at DESC
            LIMIT 1
         ) latest_last_mile ON true
         LEFT JOIN freight.vehicles last_vehicle ON last_vehicle.id = latest_last_mile.vehicle_id
         LEFT JOIN freight.drivers last_driver ON last_driver.id = last_vehicle.assigned_driver_id
         LEFT JOIN LATERAL (
           SELECT fm.vehicle_id
             FROM freight.first_mile fm
            WHERE fm.booking_id = b.id AND fm.deleted_at IS NULL
            ORDER BY fm.created_at DESC
            LIMIT 1
         ) latest_first_mile ON true
         LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id
         LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id
        WHERE inv.id = $1 AND inv.deleted_at IS NULL
        LIMIT 1`,
      [inventoryId],
    );

    return {
      bookingReference: row?.bookingReference ?? null,
      customerName: row?.customerName ?? null,
      customerPhone: row?.customerPhone ?? null,
      driverName: row?.driverName ?? null,
      driverPhone: row?.driverPhone ?? null,
      containerNumber: row?.containerNumber ?? null,
      cargoDescription: row?.cargoDescription ?? null,
    };
  }

  private async sendSms(
    recipient: string | null | undefined,
    message: string,
    context: string,
  ): Promise<void> {
    const phone = recipient?.trim();
    if (!phone) return;
    try {
      await this.notifications.directSend("sms", phone, message);
    } catch (error) {
      this.logger.error(
        `Failed to send ${context} SMS to ${phone}: ${String(error)}`,
      );
    }
  }

  private async notifyWarehouseFeeIssued(
    invoice: WarehouseFeeInvoiceView,
  ): Promise<void> {
    const contacts = await this.getInvoiceNotificationContacts(
      invoice.inventoryId,
    );
    const customerName = contacts.customerName?.trim() || "Customer";
    const bookingReference = contacts.bookingReference
      ? ` Booking: ${contacts.bookingReference}.`
      : "";
    const cargo = contacts.containerNumber || contacts.cargoDescription;
    const cargoText = cargo ? ` Cargo: ${cargo}.` : "";
    const message =
      `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ` +
      `${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` +
      `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`;

    await this.sendSms(
      contacts.customerPhone,
      message,
      `warehouse fee invoice ${invoice.invoiceNumber}`,
    );

    // In-app deep-link to pay the fee from the booking.
    if (invoice.customerId && invoice.bookingId) {
      try {
        await this.inbox.notify({
          recipients: { companyId: invoice.customerId },
          audience: NotificationAudience.PORTAL,
          type: NotificationType.INVOICE_ISSUED,
          title: "Warehouse fee due",
          body:
            `Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` +
            `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`,
          link: `/bookings/${invoice.bookingId}`,
          data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber },
        });
      } catch (err) {
        this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`);
      }
    }
  }

  private async notifyWarehouseFeePayment(
    invoice: WarehouseFeeInvoiceView,
    dto: PayInvoiceDto,
  ): Promise<void> {
    const contacts = await this.getInvoiceNotificationContacts(
      invoice.inventoryId,
    );
    const customerName = contacts.customerName?.trim() || "Customer";
    const bookingReference = contacts.bookingReference
      ? ` Booking: ${contacts.bookingReference}.`
      : "";
    const statusText =
      invoice.status === "PAID"
        ? "fully paid and ready for pickup release"
        : `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`;
    const customerMessage =
      `Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` +
      `was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`;

    await this.sendSms(
      contacts.customerPhone,
      customerMessage,
      `warehouse fee payment ${invoice.invoiceNumber}`,
    );

    if (invoice.status !== "PAID") return;

    const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone;
    const driverName =
      dto.driverName?.trim() || contacts.driverName || "Driver";
    const cargo = contacts.containerNumber || contacts.cargoDescription;
    const driverMessage =
      `Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` +
      (contacts.bookingReference
        ? ` Booking: ${contacts.bookingReference}.`
        : "") +
      (cargo ? ` Cargo: ${cargo}.` : "") +
      " Proceed with pickup after gate verification.";

    await this.sendSms(
      driverPhone,
      driverMessage,
      `warehouse pickup driver ${invoice.invoiceNumber}`,
    );
  }
}
