import { Injectable, Logger } from '@nestjs/common';
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import { CreateLoadDto, UpdateLoadSchema, LoadShipmentsSchema, DispatchLoadSchema, RecordPodSchema, ShipOrderSchema, PaginationQueryDto } from '@wms/shared';
import { z } from 'zod';
import { DbService, Tx } from '@/common/db/db.service';
import { loads, shipments, outboundOrders, outboundOrderLines, cartons, cartonLines, inventoryBalances, locations, skus, storers, carriers } 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, paginate, offsetOf, assertWarehouseAccess, resolveStorerScope, serialize, qcol } from '@/common/util';
import { LookupService } from '@/modules/master-data/lookup.service';
import { LedgerService } from '@/modules/inventory/ledger.service';
import { DocumentsService } from '@/modules/documents/documents.service';
import { OrdersService } from './orders.service';

/**
 * Loads (trucks) and goods issue. Dispatching a load posts goods issue for every
 * shipment on it: stock leaves the ledger, delivery notes and the gate pass are
 * generated, and order.shipped events fire (billing, webhooks).
 */
@Injectable()
export class ShippingService {
  private readonly logger = new Logger(ShippingService.name);

  constructor(
    private readonly db: DbService,
    private readonly lookup: LookupService,
    private readonly ledger: LedgerService,
    private readonly numbering: NumberingService,
    private readonly audit: AuditService,
    private readonly events: EventBus,
    private readonly documents: DocumentsService,
    private readonly orders: OrdersService,
  ) {}

  list(q: PaginationQueryDto & { warehouseId?: string; status?: string }) {
    return this.db.run(async (tx) => {
      const scopedStorer = resolveStorerScope();
      const where = and(
        q.warehouseId ? eq(loads.warehouseId, q.warehouseId) : undefined,
        q.status ? inArray(loads.status, q.status.split(',') as never) : undefined,
        scopedStorer ? sql`exists (select 1 from ${shipments} s2 join ${outboundOrders} o2 on o2.id = s2.order_id where s2.load_id = ${qcol(loads.id)} and o2.storer_id = ${scopedStorer})` : undefined,
      );
      const [{ count }] = await tx.select({ count: sql<number>`count(*)`.mapWith(Number) }).from(loads).where(where);
      const rows = await tx
        .select({ load: loads, shipmentCount: sql<number>`(select count(*) from ${shipments} s where s.load_id = ${qcol(loads.id)})`.mapWith(Number) })
        .from(loads)
        .where(where)
        .orderBy(desc(loads.createdAt))
        .limit(q.pageSize)
        .offset(offsetOf(q));
      return paginate(rows.map((r) => ({ ...r.load, shipmentCount: r.shipmentCount })), count, q);
    });
  }

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

  async getInTx(tx: Tx, id: string) {
    const [l] = await tx.select().from(loads).where(eq(loads.id, id)).limit(1);
    if (!l) throw new NotFoundError('Load', id);
    assertWarehouseAccess(l.warehouseId);
    const ship = await tx
      .select({ s: shipments, orderNumber: outboundOrders.orderNumber, externalRef: outboundOrders.externalRef, shipTo: outboundOrders.shipTo, orderStatus: outboundOrders.status, storerName: storers.name, stagingCode: locations.code })
      .from(shipments)
      .innerJoin(outboundOrders, eq(outboundOrders.id, shipments.orderId))
      .innerJoin(storers, eq(storers.id, outboundOrders.storerId))
      .leftJoin(locations, eq(locations.id, shipments.stagingLocationId))
      .where(eq(shipments.loadId, id));
    const dock = l.dockLocationId ? await this.lookup.location(tx, l.dockLocationId) : null;
    return serialize({ ...l, dockCode: dock?.code ?? null, shipments: ship.map((r) => ({ ...r.s, orderNumber: r.orderNumber, externalRef: r.externalRef, shipTo: r.shipTo, orderStatus: r.orderStatus, storerName: r.storerName, stagingCode: r.stagingCode })) });
  }

  create(dto: CreateLoadDto) {
    assertWarehouseAccess(dto.warehouseId);
    const ctx = requireRequestContext();
    return this.db.run(async (tx) => {
      const loadNumber = await this.numbering.next('LOAD', tx, dto.warehouseId);
      const { shipmentIds, ...head } = dto;
      let carrierName = head.carrierName ?? null;
      if (head.carrierId && !carrierName) carrierName = (await tx.select({ name: carriers.name }).from(carriers).where(eq(carriers.id, head.carrierId)).limit(1))[0]?.name ?? null;
      const [l] = await tx.insert(loads).values({ tenantId: ctx.tenantId!, ...head, carrierName, loadNumber, scheduledAt: head.scheduledAt ? new Date(head.scheduledAt) : null, createdById: ctx.userId! }).returning();
      if (shipmentIds.length) await this.attachInTx(tx, l.id, shipmentIds);
      await this.audit.log({ action: 'load.create', entityType: 'load', entityId: l.id, after: dto }, tx);
      return this.getInTx(tx, l.id);
    });
  }

  update(id: string, dto: z.infer<typeof UpdateLoadSchema>) {
    return this.db.run(async (tx) => {
      const [before] = await tx.select().from(loads).where(eq(loads.id, id)).limit(1).for('update');
      if (!before) throw new NotFoundError('Load', id);
      if (['DISPATCHED', 'DELIVERED', 'CANCELLED'].includes(before.status)) throw new DomainError(ErrorCodes.INVALID_STATE, `Load is ${before.status}`);
      await tx.update(loads).set({ ...dto, scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : undefined }).where(eq(loads.id, id));
      await this.audit.log({ action: 'load.update', entityType: 'load', entityId: id, before, after: dto }, tx);
      return this.getInTx(tx, id);
    });
  }

  attach(id: string, dto: z.infer<typeof LoadShipmentsSchema>) {
    return this.db.run(async (tx) => {
      await this.attachInTx(tx, id, dto.shipmentIds);
      return this.getInTx(tx, id);
    });
  }

  private async attachInTx(tx: Tx, loadId: string, shipmentIds: string[]) {
    const [l] = await tx.select().from(loads).where(eq(loads.id, loadId)).limit(1).for('update');
    if (!l) throw new NotFoundError('Load', loadId);
    if (!['PLANNED', 'LOADING'].includes(l.status)) throw new DomainError(ErrorCodes.INVALID_STATE, `Load is ${l.status}`);
    const rows = await tx.select().from(shipments).where(inArray(shipments.id, shipmentIds)).for('update');
    for (const s of rows) {
      if (s.warehouseId !== l.warehouseId) throw new ValidationError(`Shipment ${s.shipmentNumber} is in another warehouse`);
      if (!['PACKED', 'STAGED'].includes(s.status)) throw new DomainError(ErrorCodes.INVALID_STATE, `Shipment ${s.shipmentNumber} is ${s.status}`);
      if (s.loadId && s.loadId !== loadId) throw new DomainError(ErrorCodes.INVALID_STATE, `Shipment ${s.shipmentNumber} is on another load`);
    }
    await tx.update(shipments).set({ loadId }).where(inArray(shipments.id, shipmentIds));
    await tx.update(outboundOrders).set({ loadId }).where(inArray(outboundOrders.id, rows.map((r) => r.orderId)));
  }

  detach(id: string, shipmentId: string) {
    return this.db.run(async (tx) => {
      const [l] = await tx.select().from(loads).where(eq(loads.id, id)).limit(1).for('update');
      if (!l) throw new NotFoundError('Load', id);
      if (!['PLANNED', 'LOADING'].includes(l.status)) throw new DomainError(ErrorCodes.INVALID_STATE, `Load is ${l.status}`);
      const [s] = await tx.update(shipments).set({ loadId: null, status: sql`case when ${shipments.status} = 'LOADED' then 'STAGED'::shipment_status else ${shipments.status} end`, loadedAt: null }).where(and(eq(shipments.id, shipmentId), eq(shipments.loadId, id))).returning();
      if (s) await tx.update(outboundOrders).set({ loadId: null }).where(eq(outboundOrders.id, s.orderId));
      return this.getInTx(tx, id);
    });
  }

  /** RF: confirm a shipment physically loaded on the truck (scan the shipment / pallet). */
  confirmLoaded(id: string, shipmentId: string) {
    const ctx = requireRequestContext();
    return this.db.run(async (tx) => {
      const [l] = await tx.select().from(loads).where(eq(loads.id, id)).limit(1).for('update');
      if (!l) throw new NotFoundError('Load', id);
      if (!['PLANNED', 'LOADING'].includes(l.status)) throw new DomainError(ErrorCodes.INVALID_STATE, `Load is ${l.status}`);
      const [s] = await tx.update(shipments).set({ status: 'LOADED', loadedAt: new Date() }).where(and(eq(shipments.id, shipmentId), eq(shipments.loadId, id))).returning();
      if (!s) throw new NotFoundError('Shipment on load', shipmentId);
      await tx.update(outboundOrders).set({ status: 'LOADED' }).where(eq(outboundOrders.id, s.orderId));
      await tx.update(loads).set({ status: 'LOADING', loadingStartedAt: l.loadingStartedAt ?? new Date() }).where(eq(loads.id, id));
      await this.audit.log({ action: 'load.shipment_loaded', entityType: 'shipment', entityId: shipmentId, after: { loadId: id, by: ctx.userId } }, tx);
      return this.getInTx(tx, id);
    });
  }

  /** Dispatch = post goods issue for all shipments on the load, print DN + gate pass. */
  dispatch(id: string, dto: z.infer<typeof DispatchLoadSchema>) {
    const ctx = requireRequestContext();
    return this.db.run(async (tx) => {
      const [l] = await tx.select().from(loads).where(eq(loads.id, id)).limit(1).for('update');
      if (!l) throw new NotFoundError('Load', id);
      assertWarehouseAccess(l.warehouseId);
      if (!['PLANNED', 'LOADING', 'LOADED'].includes(l.status)) throw new DomainError(ErrorCodes.INVALID_STATE, `Load is ${l.status}`);
      const ship = await tx.select().from(shipments).where(eq(shipments.loadId, id)).for('update');
      if (!ship.length) throw new DomainError(ErrorCodes.INVALID_STATE, 'Load has no shipments');
      const wh = await this.lookup.warehouse(tx, l.warehouseId);
      if ((wh.settings as Record<string, boolean>).requireGatePass !== false && !dto.generateGatePass) throw new ValidationError('A gate pass is required to dispatch in this warehouse');
      const departedAt = dto.departedAt ? new Date(dto.departedAt) : new Date();
      const dnRefs: Record<string, unknown>[] = [];
      for (const s of ship) {
        const res = await this.postGoodsIssue(tx, s, { carrierName: l.carrierName ?? undefined, vehicleNo: l.vehicleNo ?? undefined, shippedAt: departedAt, generateDn: dto.generateDeliveryNotes });
        dnRefs.push(res);
      }
      let gatePassNumber: string | null = null;
      let gatePassDocumentId: string | null = null;
      if (dto.generateGatePass) {
        gatePassNumber = await this.numbering.next('GP', tx, l.warehouseId);
        const dock = l.dockLocationId ? await this.lookup.location(tx, l.dockLocationId) : null;
        const doc = await this.documents.generate(tx, { type: 'GATE_PASS', number: gatePassNumber, refType: 'LOAD', refId: l.id, data: { load: { ...l, sealNo: dto.sealNo ?? l.sealNo, departedAt }, shipments: dnRefs, dockCode: dock?.code } });
        gatePassDocumentId = doc.id;
      }
      const [row] = await tx
        .update(loads)
        .set({ status: 'DISPATCHED', departedAt, loadedAt: l.loadedAt ?? departedAt, sealNo: dto.sealNo ?? l.sealNo, temperatureAtDispatch: dto.temperatureAtDispatch != null ? D(dto.temperatureAtDispatch) : l.temperatureAtDispatch, gatePassNumber, gatePassDocumentId })
        .where(eq(loads.id, id))
        .returning();
      await this.audit.log({ action: 'load.dispatch', entityType: 'load', entityId: id, after: { ...dto, shipments: ship.length } }, tx);
      await this.events.publish('load.dispatched', { loadId: id, loadNumber: l.loadNumber, shipments: ship.length, gatePassNumber }, tx, { warehouseId: l.warehouseId });
      return serialize({ ...row, shipments: dnRefs });
    });
  }

  /** Direct ship of a single order (courier collection, customer pickup): goods issue without a load. */
  shipOrder(orderId: string, dto: z.infer<typeof ShipOrderSchema>) {
    return this.db.run(async (tx) => {
      const [o] = await tx.select().from(outboundOrders).where(eq(outboundOrders.id, orderId)).limit(1).for('update');
      if (!o) throw new NotFoundError('Order', orderId);
      assertWarehouseAccess(o.warehouseId);
      resolveStorerScope(o.storerId);
      const [s] = await tx.select().from(shipments).where(and(eq(shipments.orderId, orderId), inArray(shipments.status, ['PACKED', 'STAGED', 'LOADED']))).limit(1).for('update');
      if (!s) throw new DomainError(ErrorCodes.INVALID_STATE, 'Order has no packed shipment');
      const res = await this.postGoodsIssue(tx, s, { carrierName: dto.carrierName ?? o.carrierName ?? undefined, trackingNo: dto.trackingNo, shippedAt: dto.shippedAt ? new Date(dto.shippedAt) : new Date(), generateDn: true });
      return serialize(res);
    });
  }

  recordPod(id: string, dto: z.infer<typeof RecordPodSchema>) {
    return this.db.run(async (tx) => {
      const [l] = await tx.select().from(loads).where(eq(loads.id, id)).limit(1).for('update');
      if (!l) throw new NotFoundError('Load', id);
      assertWarehouseAccess(l.warehouseId);
      if (l.status !== 'DISPATCHED') throw new DomainError(ErrorCodes.INVALID_STATE, `Load is ${l.status}`);
      const [row] = await tx.update(loads).set({ status: 'DELIVERED', deliveredAt: dto.signedAt ? new Date(dto.signedAt) : new Date(), pod: dto }).where(eq(loads.id, id)).returning();
      await this.audit.log({ action: 'load.pod', entityType: 'load', entityId: id, after: dto }, tx);
      return serialize(row);
    });
  }

  // ---------------- goods issue ----------------
  /**
   * Removes the shipment's packed quantities from the staging balances, writes SHIP movements,
   * updates line/order status and generates the delivery note.
   */
  private async postGoodsIssue(tx: Tx, s: typeof shipments.$inferSelect, opts: { carrierName?: string; vehicleNo?: string; trackingNo?: string; shippedAt: Date; generateDn: boolean }) {
    const [o] = await tx.select().from(outboundOrders).where(eq(outboundOrders.id, s.orderId)).limit(1).for('update');
    const ctns = await tx.select().from(cartons).where(eq(cartons.shipmentId, s.id));
    const cls = ctns.length ? await tx.select().from(cartonLines).where(inArray(cartonLines.cartonId, ctns.map((c) => c.id))) : [];
    if (!cls.length) throw new DomainError(ErrorCodes.INVALID_STATE, `Shipment ${s.shipmentNumber} has no packed contents`);
    const lineRows = await tx.select().from(outboundOrderLines).where(eq(outboundOrderLines.orderId, o.id)).for('update');
    const cartonLpn = new Map(ctns.filter((c) => c.lpnId).map((c) => [c.id, c.lpnId!]));

    // aggregate per (sku, lot, expiry, pallet lpn)
    const groups = new Map<string, { skuId: string; lot: string; expiry: string | null; lpnId: string | null; qty: ReturnType<typeof D>; serials: string[]; lineIds: string[] }>();
    for (const cl of cls) {
      const lpnId = cartonLpn.get(cl.cartonId) ?? null;
      const k = `${cl.skuId}|${cl.lotNumber}|${cl.expiryDate ?? ''}|${lpnId ?? ''}`;
      const g = groups.get(k) ?? { skuId: cl.skuId, lot: cl.lotNumber, expiry: cl.expiryDate, lpnId, qty: ZERO, serials: [], lineIds: [] };
      g.qty = g.qty.plus(cl.qty);
      g.serials.push(...cl.serials);
      g.lineIds.push(cl.orderLineId);
      groups.set(k, g);
    }
    for (const g of groups.values()) {
      // find staging balances holding this bucket (picked stock lands in STAGING/PACKING usage locations)
      const candidates = await tx
        .select({ b: inventoryBalances })
        .from(inventoryBalances)
        .innerJoin(locations, eq(locations.id, inventoryBalances.locationId))
        .where(and(eq(inventoryBalances.warehouseId, s.warehouseId), eq(inventoryBalances.storerId, o.storerId), eq(inventoryBalances.skuId, g.skuId), eq(inventoryBalances.lotNumber, g.lot), g.expiry ? sql`${inventoryBalances.expiryDate} = ${g.expiry}` : sql`${inventoryBalances.expiryDate} is null`, eq(inventoryBalances.status, 'AVAILABLE'), inArray(locations.usage, ['STAGING', 'PACKING', 'DOCK']), sql`${inventoryBalances.qtyOnHand} > 0`))
        .orderBy(inventoryBalances.lastMovedAt)
        .for('update', { of: inventoryBalances });
      let remaining = g.qty;
      const ordered = g.lpnId ? [...candidates.filter((c) => c.b.lpnId === g.lpnId), ...candidates.filter((c) => c.b.lpnId !== g.lpnId)] : candidates;
      for (const c of ordered) {
        if (remaining.lte(0)) break;
        const take = D(c.b.qtyOnHand).minus(c.b.qtyAllocated).lt(remaining) ? D(c.b.qtyOnHand).minus(c.b.qtyAllocated) : remaining;
        if (take.lte(0)) continue;
        await this.ledger.post(tx, {
          type: 'SHIP', qty: take, warehouseId: s.warehouseId, storerId: o.storerId, skuId: g.skuId,
          from: { locationId: c.b.locationId, lpnId: c.b.lpnId, status: 'AVAILABLE' }, lotNumber: g.lot, expiryDate: g.expiry,
          serials: g.serials.splice(0, take.toNumber()), refType: 'ORDER', refId: o.id, performedAt: opts.shippedAt, notes: `Shipment ${s.shipmentNumber}`,
        });
        remaining = remaining.minus(take);
      }
      if (remaining.gt(0)) throw new DomainError(ErrorCodes.INSUFFICIENT_STOCK, `Staged stock for SKU ${g.skuId} lot "${g.lot}" is short by ${remaining}; packed quantity does not match staged stock`);
    }
    // line + order status
    for (const l of lineRows) {
      const packed = D(l.packedQty);
      if (packed.gt(0)) await tx.update(outboundOrderLines).set({ shippedQty: packed, status: packed.gte(D(l.orderedQty).minus(l.cancelledQty)) ? 'COMPLETE' : 'PARTIAL' }).where(eq(outboundOrderLines.id, l.id));
    }
    await this.orders.recomputeTotals(tx, o.id);
    const allShipped = lineRows.every((l) => D(l.packedQty).gte(D(l.orderedQty).minus(l.cancelledQty)));
    await tx.update(outboundOrders).set({ status: allShipped ? 'SHIPPED' : 'SHORT_CLOSED', shippedAt: opts.shippedAt, carrierName: opts.carrierName ?? o.carrierName }).where(eq(outboundOrders.id, o.id));

    // delivery note
    let dnNumber = s.dnNumber;
    let dnDocumentId = s.dnDocumentId;
    if (opts.generateDn && !dnDocumentId) {
      dnNumber = await this.numbering.next('DN', tx, s.warehouseId);
      const storer = await this.lookup.storer(tx, o.storerId);
      const lines = await tx.select({ l: outboundOrderLines, skuCode: skus.code, description: skus.description, uom: skus.baseUom }).from(outboundOrderLines).innerJoin(skus, eq(skus.id, outboundOrderLines.skuId)).where(eq(outboundOrderLines.orderId, o.id)).orderBy(outboundOrderLines.lineNo);
      const dnData = {
        order: o, shipment: { ...s, shippedAt: opts.shippedAt }, storerName: storer.name, carrierName: opts.carrierName, vehicleNo: opts.vehicleNo,
        lines: lines.filter((x) => D(x.l.packedQty).gt(0)).map((x) => ({ skuCode: x.skuCode, description: x.description, lotNumber: cls.find((c) => c.orderLineId === x.l.id)?.lotNumber ?? '', expiryDate: cls.find((c) => c.orderLineId === x.l.id)?.expiryDate ?? '', orderedQty: x.l.orderedQty.toString(), shippedQty: x.l.packedQty.toString(), uom: x.uom })),
      };
      const doc = await this.documents.generate(tx, { type: 'DN', number: dnNumber, refType: 'SHIPMENT', refId: s.id, data: dnData });
      dnDocumentId = doc.id;
    }
    const [sh] = await tx.update(shipments).set({ status: 'SHIPPED', shippedAt: opts.shippedAt, dnNumber, dnDocumentId, trackingNo: opts.trackingNo ?? s.trackingNo }).where(eq(shipments.id, s.id)).returning();
    await this.audit.log({ action: 'order.ship', entityType: 'order', entityId: o.id, after: { shipmentId: s.id, dnNumber } }, tx);
    await this.events.publish(allShipped ? 'order.shipped' : 'order.short_closed', { orderId: o.id, orderNumber: o.orderNumber, externalRef: o.externalRef, shipmentId: s.id, shipmentNumber: s.shipmentNumber, dnNumber, cartonCount: s.cartonCount, palletCount: s.palletCount, totalCbm: s.totalCbm?.toString() ?? null, type: o.type, lineCount: lineRows.filter((l) => D(l.packedQty).gt(0)).length, shippedQty: lineRows.reduce((a, l) => a.plus(l.packedQty), ZERO).toString() }, tx, { warehouseId: s.warehouseId, storerId: o.storerId });
    const shipTo = o.shipTo as { name?: string };
    return { shipmentId: sh.id, shipmentNumber: sh.shipmentNumber, orderId: o.id, orderNumber: o.orderNumber, externalRef: o.externalRef, shipToName: shipTo.name ?? '', cartonCount: sh.cartonCount, palletCount: sh.palletCount, totalWeightKg: sh.totalWeightKg?.toString() ?? null, dnNumber, dnDocumentId };
  }

}
