import { Injectable, Logger } from '@nestjs/common';
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
import Decimal from 'decimal.js';
import { ConfirmTaskDto } from '@wms/shared';
import { Tx } from '@/common/db/db.service';
import { tasks, allocations, outboundOrderLines, outboundOrders, lpns, inventoryBalances } from '@/db/schema';
import { AuditService } from '@/common/audit/audit.service';
import { EventBus } from '@/common/events/event-bus.service';
import { requireRequestContext } from '@/common/context/request-context';
import { DomainError, ValidationError, ErrorCodes } from '@/common/errors/domain-error';
import { D, ZERO } from '@/common/util';
import { LookupService } from '@/modules/master-data/lookup.service';
import { LedgerService } from '@/modules/inventory/ledger.service';
import { InventoryService } from '@/modules/inventory/inventory.service';
import { CountsService } from '@/modules/inventory/counts.service';
import { TasksService, TaskRow, TaskConfirmResult } from '@/modules/tasks/tasks.service';
import { AllocationService } from './allocation.service';
import { OrdersService } from './orders.service';
import { WavesService } from './waves.service';

interface PickData {
  allocationId?: string;
  allocations?: { allocationId: string; orderId: string; orderNumber: string; qty: string }[];
  waveId?: string;
  orderNumber?: string;
  serials?: string[];
}

/**
 * PICK task handler (RF). Confirms a pick from the allocated bucket into the
 * staging location, consuming the reservation. Handles partial picks, pallet
 * picks (LPN travels), tote picks (new/pick LPN at staging), serial capture at
 * pick, and short picks with automatic re-allocation from other stock.
 */
@Injectable()
export class PickingService {
  private readonly logger = new Logger(PickingService.name);

  constructor(
    private readonly lookup: LookupService,
    private readonly ledger: LedgerService,
    private readonly inventory: InventoryService,
    private readonly tasksService: TasksService,
    private readonly allocation: AllocationService,
    private readonly orders: OrdersService,
    private readonly waves: WavesService,
    private readonly audit: AuditService,
    private readonly events: EventBus,
    private readonly counts: CountsService,
  ) {
    this.tasksService.registerHandler('PICK', (tx, task, dto) => this.confirmPick(tx, task, dto));
  }

  async confirmPick(tx: Tx, task: TaskRow, dto: ConfirmTaskDto): Promise<TaskConfirmResult> {
    const ctx = requireRequestContext();
    const data = task.data as PickData;
    const sku = await this.lookup.sku(tx, task.skuId!);
    const wh = await this.lookup.warehouse(tx, task.warehouseId);
    const mode = ((wh.settings as Record<string, string>).pickConfirmationMode ?? 'SCAN_LOCATION_AND_SKU') as string;

    // ---- scan validation ----
    const fromLoc = await this.lookup.location(tx, task.fromLocationId!);
    if (mode !== 'SCAN_LPN' && dto.fromLocationCode && dto.fromLocationCode !== fromLoc.code && dto.fromLocationCode !== fromLoc.barcode && dto.fromLocationCode !== fromLoc.checkDigit) {
      throw new DomainError(ErrorCodes.WRONG_LOCATION, `Expected location ${fromLoc.code}`);
    }
    if (!dto.fromLocationCode && mode !== 'SCAN_LPN' && !dto.lpnNumber) throw new ValidationError('Scan the pick location');
    if (mode === 'SCAN_LOCATION_AND_SKU' && dto.barcode) {
      const scanned = await this.lookup.skuByBarcode(tx, dto.barcode, task.storerId ?? undefined);
      if (scanned.sku.id !== sku.id) throw new DomainError(ErrorCodes.WRONG_SKU, `Scanned ${scanned.sku.code}, expected ${sku.code}`);
    }
    let fromLpn = task.lpnId ? (await tx.select().from(lpns).where(eq(lpns.id, task.lpnId)).limit(1))[0] : null;
    if (dto.lpnNumber) {
      if (fromLpn && fromLpn.lpnNumber !== dto.lpnNumber) throw new DomainError(ErrorCodes.WRONG_LPN, `Expected LPN ${fromLpn.lpnNumber}`);
      if (!fromLpn) {
        const scannedLpn = await this.lookup.lpnByNumber(tx, dto.lpnNumber);
        if (scannedLpn && scannedLpn.locationId === fromLoc.id) fromLpn = scannedLpn;
      }
    }

    // ---- quantity ----
    const remaining = D(task.qty ?? 0).minus(task.qtyDone);
    let qty = dto.qty !== undefined ? (await this.lookup.toBaseQty(tx, sku.id, dto.qty, dto.packLevel ?? task.packLevel ?? 'EACH')).baseQty : remaining;
    if (qty.gt(remaining)) throw new ValidationError(`Pick quantity ${qty} exceeds remaining ${remaining}`);
    const shortQty = dto.shortQty ? (await this.lookup.toBaseQty(tx, sku.id, dto.shortQty, dto.packLevel ?? task.packLevel ?? 'EACH')).baseQty : ZERO;
    if (shortQty.gt(0) && !(wh.settings as Record<string, boolean>).allowShortPick) throw new DomainError(ErrorCodes.INVALID_STATE, 'Short picks are not allowed in this warehouse');
    if (qty.plus(shortQty).gt(remaining)) throw new ValidationError('Picked + short exceeds the task quantity');
    if (qty.lte(0) && shortQty.lte(0)) throw new ValidationError('Nothing to confirm');

    // serials: required at pick when the SKU is serial controlled and serials were not captured at receipt
    let serials = dto.serials ?? [];
    if (sku.serialControlled && qty.gt(0)) {
      if (data.serials?.length) {
        // order asked for specific serials: picker must scan exactly those
        const expected = new Set(data.serials);
        if (serials.length !== qty.toNumber() || serials.some((s) => !expected.has(s))) throw new DomainError(ErrorCodes.SERIAL_REQUIRED, `Scan the ${qty} requested serial numbers`);
      } else if (!serials.length) {
        throw new DomainError(ErrorCodes.SERIAL_REQUIRED, `Scan ${qty} serial numbers`);
      }
    } else {
      serials = [];
    }

    // ---- destination: staging; whole-pallet picks carry the LPN, otherwise an optional tote/pick LPN ----
    const toLoc = dto.toLocationCode ? await this.lookup.locationByCode(tx, task.warehouseId, dto.toLocationCode) : await this.lookup.location(tx, task.toLocationId!);
    let toLpnId: string | null = null;
    const fullPallet = !!fromLpn && qty.eq(await this.lpnFreeQty(tx, fromLpn.id, sku.id, task.lotNumber ?? '', task.expiryDate)) && !shortQty.gt(0);
    if (fullPallet) toLpnId = fromLpn!.id;
    else if (dto.toLpnNumber) toLpnId = (await this.inventory.resolveLpn(tx, task.warehouseId, dto.toLpnNumber, true, task.storerId))!.id;

    // ---- allocations to consume (BATCH tasks spread across several) ----
    const allocIds = data.allocationId ? [data.allocationId] : (data.allocations ?? []).map((a) => a.allocationId);
    const allocRows = await tx.select().from(allocations).where(inArray(allocations.id, allocIds)).for('update');
    if (!allocRows.length) throw new ValidationError('Task has no allocation');
    let toPick = qty;
    let toShort = shortQty;
    const touchedOrders = new Set<string>();
    for (const a of allocRows) {
      const open = D(a.qty).minus(a.pickedQty);
      if (open.lte(0)) continue;
      const pickHere = toPick.lt(open) ? toPick : open;
      if (pickHere.gt(0)) {
        // pick from the exact bucket that was reserved (its status may be non-AVAILABLE when the allocation rule allows it)
        const [reserved] = await tx.select({ status: inventoryBalances.status }).from(inventoryBalances).where(eq(inventoryBalances.id, a.balanceId)).limit(1);
        await this.ledger.post(tx, {
          type: 'PICK', qty: pickHere, warehouseId: task.warehouseId, storerId: task.storerId!, skuId: sku.id,
          from: { locationId: a.locationId, lpnId: a.lpnId, status: reserved?.status ?? 'AVAILABLE' }, to: { locationId: toLoc.id, lpnId: toLpnId, status: reserved?.status ?? 'AVAILABLE' },
          lotNumber: a.lotNumber, expiryDate: a.expiryDate, serials: serials.splice(0, pickHere.toNumber()), consumesAllocation: true,
          taskId: task.id, refType: 'ORDER', refId: a.orderId, idempotencyKey: dto.idempotencyKey ? `${dto.idempotencyKey}:${a.id}` : undefined,
        });
        await tx.update(allocations).set({ pickedQty: D(a.pickedQty).plus(pickHere), status: D(a.pickedQty).plus(pickHere).gte(a.qty) ? 'PICKED' : 'ALLOCATED' }).where(eq(allocations.id, a.id));
        await tx.update(outboundOrderLines).set({ pickedQty: sql`${outboundOrderLines.pickedQty} + ${pickHere.toFixed(4)}` }).where(eq(outboundOrderLines.id, a.orderLineId));
        toPick = toPick.minus(pickHere);
      }
      const stillOpen = open.minus(pickHere);
      if (toShort.gt(0) && stillOpen.gt(0)) {
        const shortHere = toShort.lt(stillOpen) ? toShort : stillOpen;
        await this.ledger.release(tx, a.balanceId, shortHere);
        await tx.update(allocations).set({ qty: D(a.qty).minus(shortHere), status: D(a.pickedQty).plus(pickHere).gte(D(a.qty).minus(shortHere)) ? 'PICKED' : 'ALLOCATED' }).where(eq(allocations.id, a.id));
        await tx.update(outboundOrderLines).set({ allocatedQty: sql`greatest(${outboundOrderLines.allocatedQty} - ${shortHere.toFixed(4)}, 0)` }).where(eq(outboundOrderLines.id, a.orderLineId));
        toShort = toShort.minus(shortHere);
        await this.audit.log({ action: 'pick.short', entityType: 'allocation', entityId: a.id, after: { shortQty: shortHere.toString(), reasonCodeId: dto.reasonCodeId, notes: dto.notes } }, tx);
        // the bucket is suspect: leave it for a cycle count
        await this.flagForCount(tx, task.warehouseId, a.locationId);
        // try to re-allocate the shortfall from other stock (never from the bucket that just came up short)
        const re = await this.allocation.allocateOrder(tx, a.orderId, true, { statuses: ['OPEN', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'RELEASED', 'PICKING', 'PICKED'], excludeBalanceIds: [a.balanceId], lineIds: [a.orderLineId] });
        const newAlloc = re.find((r) => r.lineId === a.orderLineId);
        if (newAlloc && newAlloc.allocationIds.length) await this.createReplacementTasks(tx, task, newAlloc.allocationIds);
        if (!newAlloc || newAlloc.short.gt(0)) {
          // Nothing (or not enough) to re-allocate: the order needs a human decision before it can be packed.
          await this.orders.flagShort(tx, a.orderId, { lineId: a.orderLineId, skuId: a.skuId, shortQty: (newAlloc?.short ?? shortHere).toString(), taskId: task.id });
        }
      }
      touchedOrders.add(a.orderId);
    }

    const done = D(task.qtyDone).plus(qty);
    const completed = done.plus(shortQty).gte(D(task.qty ?? 0)) || toPick.lte(0) && toShort.lte(0) && done.plus(shortQty).gte(D(task.qty ?? 0));
    const [row] = await tx
      .update(tasks)
      .set({ qtyDone: done, qty: shortQty.gt(0) ? D(task.qty ?? 0).minus(shortQty) : task.qty, status: completed ? 'COMPLETED' : 'IN_PROGRESS', completedAt: completed ? new Date() : null, completedById: completed ? ctx.userId : null, toLocationId: toLoc.id, toLpnId, exceptionCode: shortQty.gt(0) ? 'SHORT_PICK' : task.exceptionCode, exceptionNotes: shortQty.gt(0) ? dto.notes ?? 'Short pick' : task.exceptionNotes })
      .where(eq(tasks.id, task.id))
      .returning();

    for (const orderId of touchedOrders) {
      const status = await this.orders.refreshStatus(tx, orderId);
      if (status === 'PICKED') {
        const [o] = await tx.select({ orderNumber: outboundOrders.orderNumber, storerId: outboundOrders.storerId }).from(outboundOrders).where(eq(outboundOrders.id, orderId)).limit(1);
        await this.events.publish('order.picked', { orderId, orderNumber: o.orderNumber }, tx, { warehouseId: task.warehouseId, storerId: o.storerId });
      }
    }
    if (completed && data.waveId) await this.waves.checkCompletion(tx, data.waveId);
    if (completed && task.refType === 'WAVE' && task.refId) await this.waves.checkCompletion(tx, task.refId);
    return { task: row, completed, message: completed ? `Picked ${done} to ${toLoc.code}${shortQty.gt(0) ? ` (short ${shortQty})` : ''}` : `Partial: ${done} of ${task.qty}` };
  }

  private async lpnFreeQty(tx: Tx, lpnId: string, skuId: string, lot: string, expiry: string | null): Promise<Decimal> {
    const [row] = await tx
      .select({ q: sql<string>`coalesce(sum(${inventoryBalances.qtyOnHand}),0)`, others: sql<number>`count(*) filter (where ${inventoryBalances.skuId} <> ${skuId} or ${inventoryBalances.lotNumber} <> ${lot})`.mapWith(Number) })
      .from(inventoryBalances)
      .where(and(eq(inventoryBalances.lpnId, lpnId), sql`${inventoryBalances.qtyOnHand} > 0`));
    void expiry;
    // the LPN only travels whole if it holds nothing else
    return row.others > 0 ? D(-1) : D(row.q);
  }

  /** Pick tasks for allocations created outside a wave release (manual re-allocation of a released order). */
  async createPickTasksForAllocations(tx: Tx, orderId: string, allocationIds: string[]) {
    const [o] = await tx.select().from(outboundOrders).where(eq(outboundOrders.id, orderId)).limit(1);
    if (!o || !['RELEASED', 'PICKING', 'PICKED'].includes(o.status)) return; // not released yet: the wave release will create the tasks
    const wh = await this.lookup.warehouse(tx, o.warehouseId);
    const stagingId = (wh.settings as Record<string, string>).defaultStagingLocationId;
    if (!stagingId) throw new ValidationError('Warehouse has no default staging location configured');
    const rows = await tx.select().from(allocations).where(and(inArray(allocations.id, allocationIds), isNull(allocations.pickTaskId)));
    for (const a of rows) {
      const t = await this.tasksService.create(tx, {
        warehouseId: o.warehouseId, type: 'PICK', priority: o.priority, groupKey: o.waveId ?? undefined, storerId: o.storerId, skuId: a.skuId, qty: a.qty, fromLocationId: a.locationId, toLocationId: stagingId, lpnId: a.lpnId,
        lotNumber: a.lotNumber, expiryDate: a.expiryDate, refType: 'ORDER', refId: a.orderId, data: { orderId: a.orderId, orderNumber: o.orderNumber, allocationId: a.id, waveId: o.waveId ?? undefined, reallocation: true },
      });
      await tx.update(allocations).set({ pickTaskId: t.id }).where(eq(allocations.id, a.id));
    }
  }

  private async createReplacementTasks(tx: Tx, original: TaskRow, allocationIds: string[]) {
    const rows = await tx.select().from(allocations).where(inArray(allocations.id, allocationIds));
    for (const a of rows) {
      const t = await this.tasksService.create(tx, {
        warehouseId: original.warehouseId, type: 'PICK', priority: Math.max(1, original.priority - 1), sequence: original.sequence, groupKey: original.groupKey ?? undefined,
        storerId: original.storerId, skuId: a.skuId, qty: a.qty, packLevel: original.packLevel, fromLocationId: a.locationId, toLocationId: original.toLocationId, lpnId: a.lpnId,
        lotNumber: a.lotNumber, expiryDate: a.expiryDate, refType: 'ORDER', refId: a.orderId, assignedToId: original.assignedToId,
        data: { ...(original.data as object), allocationId: a.id, allocations: undefined, replacementFor: original.id },
      });
      await tx.update(allocations).set({ pickTaskId: t.id }).where(eq(allocations.id, a.id));
    }
  }

  /** A short pick means the system and the shelf disagree: raise a spot count for that location (once). */
  private async flagForCount(tx: Tx, warehouseId: string, locationId: string) {
    const [existing] = await tx.select({ id: tasks.id }).from(tasks).where(and(eq(tasks.type, 'COUNT'), eq(tasks.fromLocationId, locationId), inArray(tasks.status, ['PENDING', 'ASSIGNED', 'IN_PROGRESS']))).limit(1);
    if (existing) return;
    try {
      await this.counts.create({ warehouseId, type: 'SPOT', scope: { locationIds: [locationId], includeEmptyLocations: true }, blind: true, recountTolerancePct: 0, releaseNow: true, notes: 'Auto: short pick' }, tx);
    } catch (e) {
      this.logger.warn(`Could not raise spot count for ${locationId}: ${(e as Error).message}`);
    }
  }
}
