import { Injectable, Logger } from '@nestjs/common';
import { and, eq, inArray, sql, isNull, or } from 'drizzle-orm';
import Decimal from 'decimal.js';
import { RotationRule, InventoryStatus } from '@wms/shared';
import { Tx } from '@/common/db/db.service';
import { outboundOrders, outboundOrderLines, allocations, inventoryBalances, locations, zones, allocationRules, serialUnits } from '@/db/schema';
import { requireRequestContext } from '@/common/context/request-context';
import { D, ZERO, qcol } from '@/common/util';
import { LookupService, SkuRow } from '@/modules/master-data/lookup.service';
import { LedgerService } from '@/modules/inventory/ledger.service';

/** Options for re-allocation after a short pick or a manual re-run on a released order. */
export interface AllocateOptions {
  /** Order statuses allowed to (re)allocate; default OPEN, PARTIALLY_ALLOCATED, ALLOCATED. */
  statuses?: string[];
  /** Balances to skip (e.g. the bucket that just came up short). */
  excludeBalanceIds?: string[];
  /** Restrict to specific order lines. */
  lineIds?: string[];
}

export interface AllocationResult {
  orderId: string;
  lineId: string;
  skuId: string;
  requested: Decimal;
  allocated: Decimal;
  short: Decimal;
  allocationIds: string[];
}

interface Candidate {
  balance: typeof inventoryBalances.$inferSelect;
  usage: string;
  pickSequence: number;
  zonePickSequence: number;
  lpnTotal: Decimal;
}

interface EffectiveRule {
  rotation: RotationRule;
  pickFaceFirst: boolean;
  fullLpnFirst: boolean;
  allowPartialLpn: boolean;
  minShelfLifeDays: number | null;
  allowedStatuses: InventoryStatus[];
  zoneIds: string[];
}

/**
 * Allocation engine. Reserves specific stock buckets for order lines.
 *
 * Candidate stock: RESERVE / PICK locations of the order's warehouse and storer,
 * allowed statuses, matching requested lot / serials, meeting minimum shelf life.
 * Ordering: pick faces first (when enabled), then rotation rule
 * (FIFO by first receipt, FEFO by expiry, LIFO, FMFO), then location pick
 * sequence. Full-LPN preference: when the remaining demand covers a whole LPN,
 * whole LPNs are taken before partial ones (fewer touches on the floor).
 *
 * Concurrency: candidate balance rows are locked FOR UPDATE SKIP LOCKED so two
 * waves allocating the same SKU never double-book and never deadlock.
 */
@Injectable()
export class AllocationService {
  private readonly logger = new Logger(AllocationService.name);

  constructor(
    private readonly lookup: LookupService,
    private readonly ledger: LedgerService,
  ) {}

  async allocateOrder(tx: Tx, orderId: string, allowPartial = true, opts: AllocateOptions = {}): Promise<AllocationResult[]> {
    const [order] = await tx.select().from(outboundOrders).where(eq(outboundOrders.id, orderId)).limit(1).for('update');
    if (!order) return [];
    const statuses = opts.statuses ?? ['OPEN', 'PARTIALLY_ALLOCATED', 'ALLOCATED'];
    if (!statuses.includes(order.status)) return [];
    const lines = await tx.select().from(outboundOrderLines).where(and(eq(outboundOrderLines.orderId, orderId), sql`${outboundOrderLines.status} <> 'CANCELLED'`)).orderBy(outboundOrderLines.lineNo);
    const results: AllocationResult[] = [];
    for (const line of lines) {
      if (opts.lineIds && !opts.lineIds.includes(line.id)) continue;
      const remaining = D(line.orderedQty).minus(line.cancelledQty).minus(line.allocatedQty);
      if (remaining.lte(0)) {
        results.push({ orderId, lineId: line.id, skuId: line.skuId, requested: ZERO, allocated: ZERO, short: ZERO, allocationIds: [] });
        continue;
      }
      const sku = await this.lookup.sku(tx, line.skuId);
      const rule = await this.effectiveRule(tx, order.storerId, sku);
      const r = await this.allocateLine(tx, order, line, sku, rule, remaining, allowPartial, opts.excludeBalanceIds ?? []);
      results.push(r);
    }
    if (!allowPartial && results.some((r) => r.short.gt(0))) {
      // roll back this order's reservations made in this call
      for (const r of results) if (r.allocationIds.length) await this.cancelAllocations(tx, r.allocationIds);
      return results.map((r) => ({ ...r, allocated: ZERO, short: r.requested, allocationIds: [] }));
    }
    return results;
  }

  private async allocateLine(tx: Tx, order: typeof outboundOrders.$inferSelect, line: typeof outboundOrderLines.$inferSelect, sku: SkuRow, rule: EffectiveRule, remaining: Decimal, _allowPartial: boolean, excludeBalanceIds: string[] = []): Promise<AllocationResult> {
    const ctx = requireRequestContext();
    const today = new Date();
    const minExpiry = (() => {
      const days = Math.max(rule.minShelfLifeDays ?? 0, sku.minShelfLifeOutboundDays ?? 0);
      const byRule = days > 0 ? new Date(today.getTime() + days * 86400000).toISOString().slice(0, 10) : null;
      const byLine = line.minExpiryDate;
      if (byRule && byLine) return byRule > byLine ? byRule : byLine;
      return byRule ?? byLine ?? null;
    })();

    // serial-specific demand: find balances holding those serials
    let serialsByBalance: Map<string, string[]> | null = null;
    if (line.requestedSerials.length) {
      const units = await tx.select().from(serialUnits).where(and(eq(serialUnits.skuId, sku.id), inArray(serialUnits.serialNumber, line.requestedSerials), eq(serialUnits.status, 'IN_STOCK')));
      if (units.length !== line.requestedSerials.length) {
        this.logger.warn(`Order ${order.orderNumber} line ${line.lineNo}: ${line.requestedSerials.length - units.length} requested serials not in stock`);
      }
      serialsByBalance = new Map();
      for (const u of units) {
        const k = `${u.locationId}|${u.lpnId ?? ''}|${u.lotNumber}|${u.expiryDate ?? ''}`;
        serialsByBalance.set(k, [...(serialsByBalance.get(k) ?? []), u.serialNumber]);
      }
    }
    const keyOf = (c: Candidate) => `${c.balance.locationId}|${c.balance.lpnId ?? ''}|${c.balance.lotNumber}|${c.balance.expiryDate ?? ''}`;

    const rows = await tx
      .select({ balance: inventoryBalances, usage: locations.usage, pickSequence: locations.pickSequence, zonePickSequence: zones.pickSequence, zoneId: locations.zoneId, lpnTotal: sql<string>`coalesce((select sum(b2.qty_on_hand - b2.qty_allocated) from ${inventoryBalances} b2 where b2.lpn_id = ${qcol(inventoryBalances.lpnId)}), 0)` })
      .from(inventoryBalances)
      .innerJoin(locations, eq(locations.id, inventoryBalances.locationId))
      .innerJoin(zones, eq(zones.id, locations.zoneId))
      .where(
        and(
          eq(inventoryBalances.warehouseId, order.warehouseId),
          eq(inventoryBalances.storerId, order.storerId),
          eq(inventoryBalances.skuId, sku.id),
          inArray(inventoryBalances.status, rule.allowedStatuses),
          inArray(locations.usage, ['RESERVE', 'PICK']),
          eq(locations.status, 'ACTIVE'),
          sql`${inventoryBalances.qtyOnHand} > ${inventoryBalances.qtyAllocated}`,
          line.requestedLot ? eq(inventoryBalances.lotNumber, line.requestedLot) : undefined,
          minExpiry ? or(isNull(inventoryBalances.expiryDate), sql`${inventoryBalances.expiryDate} >= ${minExpiry}`) : undefined,
          rule.zoneIds.length ? inArray(locations.zoneId, rule.zoneIds) : undefined,
        ),
      )
      .for('update', { of: inventoryBalances, skipLocked: true });

    let candidates: Candidate[] = rows.filter((r) => !excludeBalanceIds.includes(r.balance.id)).map((r) => ({ balance: r.balance, usage: r.usage, pickSequence: r.pickSequence, zonePickSequence: r.zonePickSequence, lpnTotal: D(r.lpnTotal) }));
    if (serialsByBalance) candidates = candidates.filter((c) => serialsByBalance!.has(keyOf(c)));
    candidates.sort((a, b) => this.compare(a, b, rule));

    // full-LPN preference: if demand >= an LPN's total free qty, take whole LPNs first
    if (rule.fullLpnFirst) {
      const whole = candidates.filter((c) => c.balance.lpnId && c.lpnTotal.gt(0) && c.lpnTotal.lte(remaining) && c.usage === 'RESERVE');
      const rest = candidates.filter((c) => !whole.includes(c));
      candidates = [...whole, ...rest];
    }

    let toAllocate = remaining;
    const ids: string[] = [];
    for (const c of candidates) {
      if (toAllocate.lte(0)) break;
      const free = D(c.balance.qtyOnHand).minus(c.balance.qtyAllocated);
      if (free.lte(0)) continue;
      if (!rule.allowPartialLpn && c.balance.lpnId && free.gt(toAllocate)) continue; // whole pallets only
      let take = free.lt(toAllocate) ? free : toAllocate;
      let serials: string[] = [];
      if (serialsByBalance) {
        serials = serialsByBalance.get(keyOf(c)) ?? [];
        take = D(serials.length); // serial demand: exactly the requested units held in this bucket
        if (take.lte(0)) continue;
      }
      await this.ledger.reserve(tx, c.balance.id, take);
      const [a] = await tx
        .insert(allocations)
        .values({ tenantId: ctx.tenantId!, orderLineId: line.id, orderId: order.id, balanceId: c.balance.id, skuId: sku.id, locationId: c.balance.locationId, lpnId: c.balance.lpnId, lotNumber: c.balance.lotNumber, expiryDate: c.balance.expiryDate, qty: take, serials })
        .returning({ id: allocations.id });
      ids.push(a.id);
      toAllocate = toAllocate.minus(take);
    }
    const allocated = remaining.minus(toAllocate);
    if (allocated.gt(0)) {
      await tx.update(outboundOrderLines).set({ allocatedQty: sql`${outboundOrderLines.allocatedQty} + ${allocated.toFixed(4)}` }).where(eq(outboundOrderLines.id, line.id));
    }
    return { orderId: order.id, lineId: line.id, skuId: sku.id, requested: remaining, allocated, short: toAllocate, allocationIds: ids };
  }

  private compare(a: Candidate, b: Candidate, rule: EffectiveRule): number {
    if (rule.pickFaceFirst && a.usage !== b.usage) return a.usage === 'PICK' ? -1 : 1;
    const ba = a.balance;
    const bb = b.balance;
    switch (rule.rotation) {
      case 'FEFO': {
        const ea = ba.expiryDate ?? '9999-12-31';
        const eb = bb.expiryDate ?? '9999-12-31';
        if (ea !== eb) return ea < eb ? -1 : 1;
        break;
      }
      case 'LIFO':
        if (ba.firstReceivedAt.getTime() !== bb.firstReceivedAt.getTime()) return bb.firstReceivedAt.getTime() - ba.firstReceivedAt.getTime();
        break;
      case 'FMFO': {
        const ma = ba.mfgDate ?? '9999-12-31';
        const mb = bb.mfgDate ?? '9999-12-31';
        if (ma !== mb) return ma < mb ? -1 : 1;
        break;
      }
    }
    if (ba.firstReceivedAt.getTime() !== bb.firstReceivedAt.getTime()) return ba.firstReceivedAt.getTime() - bb.firstReceivedAt.getTime();
    if (a.zonePickSequence !== b.zonePickSequence) return a.zonePickSequence - b.zonePickSequence;
    return a.pickSequence - b.pickSequence;
  }

  /** Most specific active rule wins: sku > storer > tenant default; falls back to the SKU rotation. */
  private async effectiveRule(tx: Tx, storerId: string, sku: SkuRow): Promise<EffectiveRule> {
    const rules = await tx.select().from(allocationRules).where(and(eq(allocationRules.active, true), or(eq(allocationRules.skuId, sku.id), eq(allocationRules.storerId, storerId), and(isNull(allocationRules.skuId), isNull(allocationRules.storerId))))).orderBy(allocationRules.priority);
    const pick = rules.find((r) => r.skuId === sku.id) ?? rules.find((r) => r.storerId === storerId && !r.skuId) ?? rules.find((r) => !r.skuId && !r.storerId);
    const storerSettings = ((await this.lookup.storer(tx, storerId)).settings ?? {}) as { defaultRotation?: RotationRule; minShelfLifeOutboundDays?: number };
    return {
      rotation: pick?.rotation ?? storerSettings.defaultRotation ?? sku.rotationRule,
      pickFaceFirst: pick?.pickFaceFirst ?? true,
      fullLpnFirst: pick?.fullLpnFirst ?? true,
      allowPartialLpn: pick?.allowPartialLpn ?? true,
      minShelfLifeDays: pick?.minShelfLifeDays ?? storerSettings.minShelfLifeOutboundDays ?? null,
      allowedStatuses: (pick?.allowedStatuses as InventoryStatus[] | undefined) ?? ['AVAILABLE'],
      zoneIds: pick?.zoneIds ?? [],
    };
  }

  async cancelAllocations(tx: Tx, allocationIds: string[]) {
    if (!allocationIds.length) return;
    const rows = await tx.select().from(allocations).where(and(inArray(allocations.id, allocationIds), eq(allocations.status, 'ALLOCATED'))).for('update');
    for (const a of rows) {
      const open = D(a.qty).minus(a.pickedQty);
      if (open.gt(0)) await this.ledger.release(tx, a.balanceId, open);
      await tx.update(allocations).set({ status: 'CANCELLED' }).where(eq(allocations.id, a.id));
      await tx.update(outboundOrderLines).set({ allocatedQty: sql`greatest(${outboundOrderLines.allocatedQty} - ${open.toFixed(4)}, 0)` }).where(eq(outboundOrderLines.id, a.orderLineId));
    }
  }

  async deallocateOrder(tx: Tx, orderId: string) {
    const rows = await tx.select({ id: allocations.id }).from(allocations).where(and(eq(allocations.orderId, orderId), eq(allocations.status, 'ALLOCATED')));
    await this.cancelAllocations(tx, rows.map((r) => r.id));
    await tx.update(outboundOrders).set({ status: sql`case when ${outboundOrders.status} in ('ALLOCATED','PARTIALLY_ALLOCATED','RELEASED') then 'OPEN'::order_status else ${outboundOrders.status} end`, waveId: null, allocatedAt: null }).where(eq(outboundOrders.id, orderId));
  }

  /** Available-to-promise for a SKU in a warehouse: on hand, allocated, available by status. */
  async atp(tx: Tx, warehouseId: string, storerId: string, skuId: string) {
    const [row] = await tx
      .select({ onHand: sql<string>`coalesce(sum(${inventoryBalances.qtyOnHand}),0)`, allocated: sql<string>`coalesce(sum(${inventoryBalances.qtyAllocated}),0)` })
      .from(inventoryBalances)
      .innerJoin(locations, eq(locations.id, inventoryBalances.locationId))
      .where(and(eq(inventoryBalances.warehouseId, warehouseId), eq(inventoryBalances.storerId, storerId), eq(inventoryBalances.skuId, skuId), eq(inventoryBalances.status, 'AVAILABLE'), inArray(locations.usage, ['RESERVE', 'PICK'])));
    return { onHand: D(row.onHand), allocated: D(row.allocated), available: D(row.onHand).minus(row.allocated) };
  }

}
