import { Injectable, Logger } from '@nestjs/common';
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
import Decimal from 'decimal.js';
import { PutawayCriteria, PackLevel, StorageCondition, TemperatureClass, ItemCondition } from '@wms/shared';
import { Tx } from '@/common/db/db.service';
import { putawayRules, locations, zones, inventoryBalances, skuSlottings, skus, tasks } from '@/db/schema';
import { D, ZERO } from '@/common/util';
import { LookupService, SkuRow } from '@/modules/master-data/lookup.service';

export interface PutawayRequest {
  warehouseId: string;
  storerId: string;
  sku: SkuRow;
  qty: Decimal; // base units
  packLevel: PackLevel;
  lotNumber: string;
  lpnId: string | null;
  condition: ItemCondition;
  asnType?: string;
  excludeLocationIds?: string[];
}

export interface PutawaySuggestion {
  locationId: string;
  locationCode: string;
  zoneCode: string;
  ruleId: string | null;
  ruleName: string;
  strategy: string;
  reason: string;
}

type LocationCandidate = typeof locations.$inferSelect & { zoneCode: string; temperatureClass: TemperatureClass; zonePickSeq: number };

/** Storage conditions a SKU may be stored under, by zone temperature class. */
const COMPATIBLE: Record<StorageCondition, TemperatureClass[]> = {
  AMBIENT: ['AMBIENT', 'AIR_CONDITIONED'],
  AIR_CONDITIONED: ['AIR_CONDITIONED'],
  CHILLED: ['CHILLED'],
  FROZEN: ['FROZEN', 'DEEP_FROZEN'],
  DEEP_FROZEN: ['DEEP_FROZEN'],
  HAZMAT: ['HAZMAT'],
};

/**
 * Directed putaway. Evaluates the warehouse's rules in priority order; the first
 * rule whose criteria match AND whose strategy yields a location wins. Falls back
 * to "any compatible empty storage location". Damaged / rejected stock is always
 * directed to the DAMAGED / QUARANTINE areas regardless of rules.
 */
@Injectable()
export class PutawayService {
  private readonly logger = new Logger(PutawayService.name);

  constructor(private readonly lookup: LookupService) {}

  async suggest(tx: Tx, req: PutawayRequest): Promise<PutawaySuggestion | null> {
    if (req.condition !== 'GOOD') {
      const usage = req.condition === 'DAMAGED' ? 'DAMAGED' : 'QUARANTINE';
      const [loc] = await tx
        .select({ loc: locations, zoneCode: zones.code })
        .from(locations)
        .innerJoin(zones, eq(zones.id, locations.zoneId))
        .where(and(eq(locations.warehouseId, req.warehouseId), eq(locations.usage, usage), eq(locations.status, 'ACTIVE')))
        .orderBy(asc(locations.putawaySequence))
        .limit(1);
      return loc ? { locationId: loc.loc.id, locationCode: loc.loc.code, zoneCode: loc.zoneCode, ruleId: null, ruleName: 'Condition routing', strategy: usage, reason: `${req.condition} stock goes to ${usage}` } : null;
    }

    const rules = await tx.select().from(putawayRules).where(and(eq(putawayRules.warehouseId, req.warehouseId), eq(putawayRules.active, true))).orderBy(asc(putawayRules.priority));
    const candidates = await this.loadCandidates(tx, req);

    for (const rule of rules) {
      if (!this.matches(rule.criteria as PutawayCriteria, req)) continue;
      const pool = rule.targetZoneIds.length ? candidates.filter((c) => rule.targetZoneIds.includes(c.zoneId)) : candidates;
      const hit = await this.applyStrategy(tx, rule.strategy, pool, req, { allowMixedSku: rule.allowMixedSku, allowMixedLots: rule.allowMixedLots, respectCapacity: rule.respectCapacity });
      if (hit) return { ...hit, ruleId: rule.id, ruleName: rule.name, strategy: rule.strategy };
    }

    // fallback: compatible empty storage location
    const fallback = await this.applyStrategy(tx, 'EMPTY_LOCATION', candidates, req, { allowMixedSku: false, allowMixedLots: false, respectCapacity: true });
    if (fallback) return { ...fallback, ruleId: null, ruleName: 'Default', strategy: 'EMPTY_LOCATION' };
    // last resort: same sku anywhere compatible
    const same = await this.applyStrategy(tx, 'SAME_SKU', candidates, req, { allowMixedSku: true, allowMixedLots: true, respectCapacity: true });
    if (same) return { ...same, ruleId: null, ruleName: 'Default', strategy: 'SAME_SKU' };
    this.logger.warn(`No putaway location for SKU ${req.sku.code} in warehouse ${req.warehouseId}`);
    return null;
  }

  /** Validates an operator-chosen destination (RF override). Returns a reason string if not allowed. */
  async validateDestination(tx: Tx, req: PutawayRequest, locationId: string): Promise<string | null> {
    const [row] = await tx
      .select({ loc: locations, temperatureClass: zones.temperatureClass, zoneType: zones.type })
      .from(locations)
      .innerJoin(zones, eq(zones.id, locations.zoneId))
      .where(eq(locations.id, locationId))
      .limit(1);
    if (!row) return 'Location not found';
    if (row.loc.warehouseId !== req.warehouseId) return 'Location is in another warehouse';
    if (row.loc.status !== 'ACTIVE') return `Location is ${row.loc.status}`;
    if (req.condition === 'GOOD' && !COMPATIBLE[req.sku.storageCondition].includes(row.temperatureClass)) return `Zone temperature class ${row.temperatureClass} is not suitable for ${req.sku.storageCondition} goods`;
    if (req.condition === 'DAMAGED' && row.loc.usage !== 'DAMAGED') return 'Damaged stock must go to a DAMAGED location';
    if (req.condition === 'REJECTED' && row.loc.usage !== 'QUARANTINE') return 'Rejected stock must go to a QUARANTINE location';
    if (['DOCK', 'STAGING', 'PACKING'].includes(row.loc.usage)) return `Cannot put away into a ${row.loc.usage} location`;
    return null;
  }

  // ------------------------------------------------------------------
  private matches(c: PutawayCriteria, req: PutawayRequest): boolean {
    if (c.storerIds?.length && !c.storerIds.includes(req.storerId)) return false;
    if (c.storageConditions?.length && !c.storageConditions.includes(req.sku.storageCondition)) return false;
    if (c.categories?.length && !(req.sku.category && c.categories.includes(req.sku.category))) return false;
    if (c.hazmat !== undefined && c.hazmat !== req.sku.hazmat) return false;
    if (c.packLevels?.length && !c.packLevels.includes(req.packLevel)) return false;
    if (c.abcClasses?.length && !(req.sku.abcClass && c.abcClasses.includes(req.sku.abcClass))) return false;
    if (c.skuIds?.length && !c.skuIds.includes(req.sku.id)) return false;
    if (c.asnTypes?.length && !(req.asnType && c.asnTypes.includes(req.asnType))) return false;
    return true;
  }

  private async loadCandidates(tx: Tx, req: PutawayRequest): Promise<LocationCandidate[]> {
    const rows = await tx
      .select({ loc: locations, zoneCode: zones.code, temperatureClass: zones.temperatureClass, zonePickSeq: zones.pickSequence })
      .from(locations)
      .innerJoin(zones, eq(zones.id, locations.zoneId))
      .where(
        and(
          eq(locations.warehouseId, req.warehouseId),
          eq(locations.status, 'ACTIVE'),
          eq(zones.status, 'ACTIVE'),
          inArray(zones.type, ['STORAGE', 'PICK']),
          inArray(locations.usage, ['RESERVE', 'PICK']),
          inArray(zones.temperatureClass, COMPATIBLE[req.sku.storageCondition]),
          req.excludeLocationIds?.length ? sql`${locations.id} not in ${req.excludeLocationIds}` : undefined,
        ),
      )
      .orderBy(asc(zones.pickSequence), asc(locations.putawaySequence), asc(locations.code));
    return rows.map((r) => ({ ...r.loc, zoneCode: r.zoneCode, temperatureClass: r.temperatureClass, zonePickSeq: r.zonePickSeq }));
  }

  private async applyStrategy(
    tx: Tx,
    strategy: string,
    pool: LocationCandidate[],
    req: PutawayRequest,
    opts: { allowMixedSku: boolean; allowMixedLots: boolean; respectCapacity: boolean },
  ): Promise<Omit<PutawaySuggestion, 'ruleId' | 'ruleName' | 'strategy'> | null> {
    if (!pool.length) return null;
    const ids = pool.map((p) => p.id);
    // occupancy per candidate location
    const occ = await tx
      .select({
        locationId: inventoryBalances.locationId,
        qty: sql<string>`sum(${inventoryBalances.qtyOnHand})`,
        cbm: sql<string>`sum(coalesce(${skus.cbm},0) * ${inventoryBalances.qtyOnHand})`,
        weight: sql<string>`sum(coalesce(${skus.weightKg},0) * ${inventoryBalances.qtyOnHand})`,
        lpnCount: sql<number>`count(distinct ${inventoryBalances.lpnId})`.mapWith(Number),
        skuCount: sql<number>`count(distinct ${inventoryBalances.skuId})`.mapWith(Number),
        sameSkuQty: sql<string>`sum(case when ${inventoryBalances.skuId} = ${req.sku.id} then ${inventoryBalances.qtyOnHand} else 0 end)`,
        otherLotQty: sql<string>`sum(case when ${inventoryBalances.skuId} = ${req.sku.id} and ${inventoryBalances.lotNumber} <> ${req.lotNumber} then ${inventoryBalances.qtyOnHand} else 0 end)`,
      })
      .from(inventoryBalances)
      .innerJoin(skus, eq(skus.id, inventoryBalances.skuId))
      .where(and(inArray(inventoryBalances.locationId, ids), sql`${inventoryBalances.qtyOnHand} > 0`))
      .groupBy(inventoryBalances.locationId);
    const occMap = new Map(occ.map((o) => [o.locationId, o]));
    // Open putaway/move tasks heading for these locations count as occupancy too, otherwise two receipts in a row get the same empty slot.
    const inflight = await tx
      .select({ locationId: tasks.toLocationId, skuId: tasks.skuId, lotNumber: tasks.lotNumber, lpns: sql<number>`count(distinct ${tasks.lpnId})`.mapWith(Number), qty: sql<string>`coalesce(sum(${tasks.qty}),0)` })
      .from(tasks)
      .where(and(inArray(tasks.toLocationId, ids), inArray(tasks.type, ['PUTAWAY', 'MOVE', 'REPLENISH']), inArray(tasks.status, ['PENDING', 'ASSIGNED', 'IN_PROGRESS'])))
      .groupBy(tasks.toLocationId, tasks.skuId, tasks.lotNumber);
    for (const f of inflight) {
      if (!f.locationId) continue;
      const o = occMap.get(f.locationId) ?? { locationId: f.locationId, qty: '0', cbm: '0', weight: '0', lpnCount: 0, skuCount: 0, sameSkuQty: '0', otherLotQty: '0' };
      o.lpnCount += f.lpns;
      if (f.skuId === req.sku.id) {
        o.sameSkuQty = D(o.sameSkuQty).plus(f.qty).toString();
        if ((f.lotNumber ?? '') !== req.lotNumber) o.otherLotQty = D(o.otherLotQty).plus(f.qty).toString();
      } else if (f.skuId) {
        o.skuCount += 1;
      }
      occMap.set(f.locationId, o);
    }
    const incoming = this.lookup.cubeAndWeight(req.sku, req.qty);

    const fits = (loc: LocationCandidate): boolean => {
      const o = occMap.get(loc.id);
      if (o) {
        if (!opts.allowMixedSku && o.skuCount > 0 && D(o.sameSkuQty).lte(0)) return false;
        if (!loc.allowMixedSku && o.skuCount > 0 && D(o.sameSkuQty).lte(0)) return false;
        if (!opts.allowMixedLots && D(o.otherLotQty).gt(0)) return false;
        if (!loc.allowMixedLots && D(o.otherLotQty).gt(0)) return false;
      }
      if (!opts.respectCapacity) return true;
      const lpnCount = o?.lpnCount ?? 0;
      if (loc.maxLpns && req.lpnId && lpnCount + 1 > loc.maxLpns) return false;
      if (loc.cubicCapacityM3 && incoming.cbm && D(o?.cbm ?? 0).plus(incoming.cbm).gt(loc.cubicCapacityM3)) return false;
      if (loc.weightCapacityKg && incoming.weightKg && D(o?.weight ?? 0).plus(incoming.weightKg).gt(loc.weightCapacityKg)) return false;
      return true;
    };

    const pick = (loc: LocationCandidate, reason: string) => ({ locationId: loc.id, locationCode: loc.code, zoneCode: loc.zoneCode, reason });

    switch (strategy) {
      case 'FIXED_LOCATION': {
        const slots = await tx.select().from(skuSlottings).where(and(eq(skuSlottings.skuId, req.sku.id), eq(skuSlottings.warehouseId, req.warehouseId), eq(skuSlottings.active, true)));
        for (const s of slots) {
          const loc = pool.find((p) => p.id === s.pickLocationId);
          if (!loc) continue;
          const current = D(occMap.get(loc.id)?.sameSkuQty ?? 0);
          if (current.plus(req.qty).lte(s.maxQty) && fits(loc)) return pick(loc, `Fixed pick face (${current}/${s.maxQty})`);
        }
        return null;
      }
      case 'SAME_SKU': {
        const same = pool.filter((p) => D(occMap.get(p.id)?.sameSkuQty ?? 0).gt(0) && fits(p));
        return same.length ? pick(same[0], 'Consolidate with existing stock of the same SKU') : null;
      }
      case 'EMPTY_LOCATION':
      case 'NEAREST_TO_DOCK': {
        const empty = pool.filter((p) => !occMap.get(p.id) && p.usage === 'RESERVE' && fits(p));
        return empty.length ? pick(empty[0], strategy === 'NEAREST_TO_DOCK' ? 'Nearest empty location to the dock' : 'First empty location in sequence') : null;
      }
      case 'ZONE_SEQUENCE': {
        const any = pool.filter((p) => p.usage === 'RESERVE' && fits(p));
        return any.length ? pick(any[0], 'First location with capacity in zone sequence') : null;
      }
      default:
        return null;
    }
  }

  /** Quantity currently occupying the pick face vs. its max (for replenishment). */
  async pickFaceGap(tx: Tx, slotting: typeof skuSlottings.$inferSelect): Promise<{ current: Decimal; gap: Decimal }> {
    const [row] = await tx
      .select({ qty: sql<string>`coalesce(sum(${inventoryBalances.qtyOnHand}),0)` })
      .from(inventoryBalances)
      .where(and(eq(inventoryBalances.locationId, slotting.pickLocationId), eq(inventoryBalances.skuId, slotting.skuId), eq(inventoryBalances.status, 'AVAILABLE')));
    const current = D(row?.qty ?? 0);
    const gap = D(slotting.maxQty).minus(current);
    return { current, gap: gap.gt(0) ? gap : ZERO };
  }
}
