import { Injectable } from '@nestjs/common';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { PackCartonDto, StartPackingSchema, CompletePackingSchema } from '@wms/shared';
import { z } from 'zod';
import { DbService, Tx } from '@/common/db/db.service';
import { shipments, cartons, cartonLines, outboundOrders, outboundOrderLines, skus } from '@/db/schema';
import { AuditService } from '@/common/audit/audit.service';
import { NumberingService } from '@/common/numbering/numbering.service';
import { EventBus } from '@/common/events/event-bus.service';
import { requireRequestContext } from '@/common/context/request-context';
import { DomainError, NotFoundError, ValidationError, ErrorCodes } from '@/common/errors/domain-error';
import { D, ZERO, sum, serialize, assertWarehouseAccess } from '@/common/util';
import { LookupService } from '@/modules/master-data/lookup.service';
import { InventoryService } from '@/modules/inventory/inventory.service';
import { OrdersService } from './orders.service';
import { DocumentsService } from '@/modules/documents/documents.service';

/**
 * Packing: picked stock at staging is verified and packed into cartons / pallets.
 * A shipment is the packed, shippable unit for one order. Packing does not move
 * stock in the ledger (it is already at staging); it records the carton
 * contents used by the packing list, delivery note and carton labels.
 */
@Injectable()
export class PackingService {
  constructor(
    private readonly db: DbService,
    private readonly lookup: LookupService,
    private readonly inventory: InventoryService,
    private readonly numbering: NumberingService,
    private readonly audit: AuditService,
    private readonly events: EventBus,
    private readonly orders: OrdersService,
    private readonly documents: DocumentsService,
  ) {}

  start(dto: z.infer<typeof StartPackingSchema>) {
    const ctx = requireRequestContext();
    return this.db.run(async (tx) => {
      const [o] = await tx.select().from(outboundOrders).where(eq(outboundOrders.id, dto.orderId)).limit(1).for('update');
      if (!o) throw new NotFoundError('Order', dto.orderId);
      assertWarehouseAccess(o.warehouseId);
      if (!['PICKED', 'PICKING', 'PACKED'].includes(o.status)) throw new DomainError(ErrorCodes.INVALID_STATE, `Order is ${o.status}; nothing picked yet`);
      if ((o.attributes as Record<string, unknown>)?.shortPending) throw new DomainError(ErrorCodes.INVALID_STATE, 'Order has an unresolved short pick: re-allocate it or accept the short shipment before packing');
      const [existing] = await tx.select().from(shipments).where(and(eq(shipments.orderId, o.id), inArray(shipments.status, ['PACKING', 'PACKED']))).limit(1);
      if (existing) return this.getShipmentInTx(tx, existing.id);
      const wh = await this.lookup.warehouse(tx, o.warehouseId);
      const packingLocationId = dto.packingLocationId ?? (wh.settings as Record<string, string>).defaultPackingLocationId ?? null;
      const shipmentNumber = await this.numbering.next('SHP', tx, o.warehouseId);
      const [s] = await tx.insert(shipments).values({ tenantId: ctx.tenantId!, warehouseId: o.warehouseId, orderId: o.id, shipmentNumber, packingLocationId, carrierId: o.carrierId }).returning();
      await this.audit.log({ action: 'packing.start', entityType: 'shipment', entityId: s.id, after: dto }, tx);
      return this.getShipmentInTx(tx, s.id);
    });
  }

  packCarton(dto: PackCartonDto) {
    const ctx = requireRequestContext();
    return this.db.run(async (tx) => {
      const [s] = await tx.select().from(shipments).where(eq(shipments.id, dto.shipmentId)).limit(1).for('update');
      if (!s) throw new NotFoundError('Shipment', dto.shipmentId);
      if (s.status !== 'PACKING') throw new DomainError(ErrorCodes.INVALID_STATE, `Shipment is ${s.status}`);
      const lineIds = dto.lines.map((l) => l.orderLineId);
      const lines = await tx.select().from(outboundOrderLines).where(and(inArray(outboundOrderLines.id, lineIds), eq(outboundOrderLines.orderId, s.orderId))).for('update');
      if (lines.length !== new Set(lineIds).size) throw new ValidationError('One or more lines do not belong to this order');
      const cartonNumber = dto.cartonNumber ?? (await this.numbering.next('CTN', tx, s.warehouseId));
      const lpn = dto.type === 'PALLET' ? await this.inventory.resolveLpn(tx, s.warehouseId, dto.lpnNumber, true) : null;
      const [c] = await tx
        .insert(cartons)
        .values({ tenantId: ctx.tenantId!, shipmentId: s.id, cartonNumber, type: dto.type, lpnId: lpn?.id ?? null, weightKg: dto.weightKg ? D(dto.weightKg) : null, lengthCm: dto.lengthCm ? D(dto.lengthCm) : null, widthCm: dto.widthCm ? D(dto.widthCm) : null, heightCm: dto.heightCm ? D(dto.heightCm) : null, trackingNo: dto.trackingNo ?? null })
        .returning();
      for (const l of dto.lines) {
        const line = lines.find((x) => x.id === l.orderLineId)!;
        const sku = await this.lookup.sku(tx, line.skuId);
        const { baseQty } = await this.lookup.toBaseQty(tx, sku.id, l.qty, l.packLevel ?? 'EACH');
        const packable = D(line.pickedQty).minus(line.packedQty);
        if (baseQty.gt(packable)) throw new ValidationError(`Line ${line.lineNo}: packing ${baseQty} but only ${packable} picked and unpacked`);
        if (sku.serialControlled && l.serials.length !== baseQty.toNumber()) throw new DomainError(ErrorCodes.SERIAL_REQUIRED, `Line ${line.lineNo}: scan ${baseQty} serials`);
        await tx.insert(cartonLines).values({ tenantId: ctx.tenantId!, cartonId: c.id, orderLineId: line.id, skuId: sku.id, qty: baseQty, lotNumber: l.lotNumber ?? '', expiryDate: l.expiryDate ?? null, serials: l.serials });
        await tx.update(outboundOrderLines).set({ packedQty: D(line.packedQty).plus(baseQty) }).where(eq(outboundOrderLines.id, line.id));
        line.packedQty = D(line.packedQty).plus(baseQty);
      }
      await this.refreshShipmentTotals(tx, s.id);
      await this.audit.log({ action: 'packing.carton', entityType: 'carton', entityId: c.id, after: dto }, tx);
      return this.getShipmentInTx(tx, s.id);
    });
  }

  removeCarton(cartonId: string) {
    return this.db.run(async (tx) => {
      const [c] = await tx.select().from(cartons).where(eq(cartons.id, cartonId)).limit(1).for('update');
      if (!c) throw new NotFoundError('Carton', cartonId);
      const [s] = await tx.select().from(shipments).where(eq(shipments.id, c.shipmentId)).limit(1).for('update');
      if (s.status !== 'PACKING') throw new DomainError(ErrorCodes.INVALID_STATE, `Shipment is ${s.status}`);
      const lines = await tx.select().from(cartonLines).where(eq(cartonLines.cartonId, cartonId));
      for (const l of lines) await tx.update(outboundOrderLines).set({ packedQty: sql`greatest(${outboundOrderLines.packedQty} - ${D(l.qty).toFixed(4)}, 0)` }).where(eq(outboundOrderLines.id, l.orderLineId));
      await tx.delete(cartons).where(eq(cartons.id, cartonId));
      await this.refreshShipmentTotals(tx, s.id);
      await this.audit.log({ action: 'packing.carton.remove', entityType: 'carton', entityId: cartonId, before: c }, tx);
      return this.getShipmentInTx(tx, s.id);
    });
  }

  /** All picked quantity must be packed. Stock is moved to the staging lane if a different one is given. */
  complete(dto: z.infer<typeof CompletePackingSchema>) {
    return this.db.run(async (tx) => {
      const [s] = await tx.select().from(shipments).where(eq(shipments.id, dto.shipmentId)).limit(1).for('update');
      if (!s) throw new NotFoundError('Shipment', dto.shipmentId);
      if (s.status !== 'PACKING') throw new DomainError(ErrorCodes.INVALID_STATE, `Shipment is ${s.status}`);
      const lines = await tx.select().from(outboundOrderLines).where(eq(outboundOrderLines.orderId, s.orderId));
      const unpacked = lines.filter((l) => D(l.pickedQty).gt(l.packedQty));
      if (unpacked.length) throw new DomainError(ErrorCodes.INVALID_STATE, `Unpacked picked quantity on lines ${unpacked.map((l) => l.lineNo).join(', ')}`);
      if (!lines.some((l) => D(l.packedQty).gt(0))) throw new DomainError(ErrorCodes.INVALID_STATE, 'Nothing packed');
      const [o] = await tx.select().from(outboundOrders).where(eq(outboundOrders.id, s.orderId)).limit(1).for('update');
      const wh = await this.lookup.warehouse(tx, s.warehouseId);
      const stagingId = dto.stagingLocationId ?? (dto.stagingLocationCode ? (await this.lookup.locationByCode(tx, s.warehouseId, dto.stagingLocationCode)).id : (wh.settings as Record<string, string>).defaultStagingLocationId);
      // Stock stays where the picks landed (staging); the lane is recorded on the shipment for the loader.
      const [sh] = await tx.update(shipments).set({ status: 'PACKED', stagingLocationId: stagingId ?? null, packedAt: new Date(), stagedAt: stagingId ? new Date() : null }).where(eq(shipments.id, s.id)).returning();
      await tx.update(outboundOrders).set({ status: stagingId ? 'STAGED' : 'PACKED', packedAt: new Date() }).where(eq(outboundOrders.id, s.orderId));
      const data = await this.packingListData(tx, sh.id);
      const doc = await this.documents.generate(tx, { type: 'PACKING_LIST', number: `PKL-${sh.shipmentNumber}`, refType: 'SHIPMENT', refId: sh.id, data });
      await tx.update(shipments).set({ packingListDocumentId: doc.id, status: stagingId ? 'STAGED' : 'PACKED' }).where(eq(shipments.id, sh.id));
      await this.audit.log({ action: 'packing.complete', entityType: 'shipment', entityId: sh.id, after: dto }, tx);
      await this.events.publish('order.packed', { orderId: o.id, orderNumber: o.orderNumber, shipmentId: sh.id, shipmentNumber: sh.shipmentNumber, cartonCount: sh.cartonCount }, tx, { warehouseId: s.warehouseId, storerId: o.storerId });
      return this.getShipmentInTx(tx, sh.id);
    });
  }

  getShipment(id: string) {
    return this.db.run((tx) => this.getShipmentInTx(tx, id));
  }

  async getShipmentInTx(tx: Tx, id: string) {
    const [s] = await tx.select().from(shipments).where(eq(shipments.id, id)).limit(1);
    if (!s) throw new NotFoundError('Shipment', id);
    const [o] = await tx.select().from(outboundOrders).where(eq(outboundOrders.id, s.orderId)).limit(1);
    const lines = await tx.select({ line: outboundOrderLines, skuCode: skus.code, description: skus.description, serialControlled: skus.serialControlled }).from(outboundOrderLines).innerJoin(skus, eq(skus.id, outboundOrderLines.skuId)).where(eq(outboundOrderLines.orderId, s.orderId)).orderBy(outboundOrderLines.lineNo);
    const ctns = await tx.select().from(cartons).where(eq(cartons.shipmentId, id)).orderBy(cartons.createdAt);
    const ctnLines = ctns.length ? await tx.select().from(cartonLines).where(inArray(cartonLines.cartonId, ctns.map((c) => c.id))) : [];
    const skuMap = new Map(lines.map((l) => [l.line.id, l]));
    return serialize({
      ...s,
      order: { id: o.id, orderNumber: o.orderNumber, externalRef: o.externalRef, status: o.status, shipTo: o.shipTo, storerId: o.storerId, carrierName: o.carrierName },
      lines: lines.map((l) => ({ ...l.line, skuCode: l.skuCode, description: l.description, serialControlled: l.serialControlled, toPack: D(l.line.pickedQty).minus(l.line.packedQty).toString() })),
      cartons: ctns.map((c) => ({ ...c, lines: ctnLines.filter((cl) => cl.cartonId === c.id).map((cl) => ({ ...cl, skuCode: skuMap.get(cl.orderLineId)?.skuCode, description: skuMap.get(cl.orderLineId)?.description })) })),
    });
  }

  async packingListData(tx: Tx, shipmentId: string) {
    const sh = await this.getShipmentInTx(tx, shipmentId);
    const [o] = await tx.select().from(outboundOrders).where(eq(outboundOrders.id, (sh as { orderId: string }).orderId)).limit(1);
    return { shipment: sh, order: o, cartons: (sh as { cartons: unknown[] }).cartons };
  }

  private async refreshShipmentTotals(tx: Tx, shipmentId: string) {
    const ctns = await tx.select().from(cartons).where(eq(cartons.shipmentId, shipmentId));
    const lines = ctns.length ? await tx.select({ cl: cartonLines, cbm: skus.cbm, weight: skus.weightKg }).from(cartonLines).innerJoin(skus, eq(skus.id, cartonLines.skuId)).where(inArray(cartonLines.cartonId, ctns.map((c) => c.id))) : [];
    const contentWeight = lines.reduce((a, l) => a.plus(l.weight ? D(l.weight).mul(l.cl.qty) : ZERO), ZERO);
    const declared = sum(ctns.map((c) => c.weightKg ?? ZERO));
    const cbm = lines.reduce((a, l) => a.plus(l.cbm ? D(l.cbm).mul(l.cl.qty) : ZERO), ZERO);
    await tx
      .update(shipments)
      .set({ cartonCount: ctns.filter((c) => c.type === 'CARTON').length, palletCount: ctns.filter((c) => c.type === 'PALLET').length, totalWeightKg: declared.gt(0) ? declared : contentWeight, totalCbm: cbm })
      .where(eq(shipments.id, shipmentId));
  }
}
