import { Injectable } from '@nestjs/common';
import { and, desc, eq, sql } from 'drizzle-orm';
import { CreateAdjustmentDto, PaginationQueryDto, hasPermission, DecideAdjustmentSchema } from '@wms/shared';
import { z } from 'zod';
import { DbService, Tx } from '@/common/db/db.service';
import { inventoryAdjustments, inventoryAdjustmentLines, reasonCodes, skus, locations, lpns } 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, ForbiddenError } from '@/common/errors/domain-error';
import { D, paginate, offsetOf, assertWarehouseAccess, resolveStorerScope, serialize } from '@/common/util';
import { LookupService } from '@/modules/master-data/lookup.service';
import { LedgerService } from './ledger.service';

/**
 * Inventory adjustments with a four-eyes workflow:
 * DRAFT -> PENDING_APPROVAL -> APPROVED -> POSTED (or REJECTED).
 * The requester cannot approve their own adjustment. Posting writes ledger
 * movements; nothing touches stock before that.
 */
@Injectable()
export class AdjustmentsService {
  constructor(
    private readonly db: DbService,
    private readonly ledger: LedgerService,
    private readonly lookup: LookupService,
    private readonly numbering: NumberingService,
    private readonly audit: AuditService,
    private readonly events: EventBus,
  ) {}

  list(q: PaginationQueryDto & { warehouseId?: string; storerId?: string; status?: string }) {
    const storerId = resolveStorerScope(q.storerId);
    return this.db.run(async (tx) => {
      const where = and(
        q.warehouseId ? eq(inventoryAdjustments.warehouseId, q.warehouseId) : undefined,
        storerId ? eq(inventoryAdjustments.storerId, storerId) : undefined,
        q.status ? eq(inventoryAdjustments.status, q.status as never) : undefined,
      );
      const [{ count }] = await tx.select({ count: sql<number>`count(*)`.mapWith(Number) }).from(inventoryAdjustments).where(where);
      const rows = await tx.select().from(inventoryAdjustments).where(where).orderBy(desc(inventoryAdjustments.createdAt)).limit(q.pageSize).offset(offsetOf(q));
      return paginate(rows, count, q);
    });
  }

  get(id: string) {
    return this.db.run(async (tx) => {
      const [adj] = await tx.select().from(inventoryAdjustments).where(eq(inventoryAdjustments.id, id)).limit(1);
      if (!adj) throw new NotFoundError('Adjustment', id);
      const lines = await tx
        .select({ line: inventoryAdjustmentLines, skuCode: skus.code, skuDescription: skus.description, locationCode: locations.code, lpnNumber: lpns.lpnNumber })
        .from(inventoryAdjustmentLines)
        .innerJoin(skus, eq(skus.id, inventoryAdjustmentLines.skuId))
        .innerJoin(locations, eq(locations.id, inventoryAdjustmentLines.locationId))
        .leftJoin(lpns, eq(lpns.id, inventoryAdjustmentLines.lpnId))
        .where(eq(inventoryAdjustmentLines.adjustmentId, id));
      const [reason] = await tx.select().from(reasonCodes).where(eq(reasonCodes.id, adj.reasonCodeId)).limit(1);
      return serialize({ ...adj, reasonCode: reason, lines: lines.map((l) => ({ ...l.line, skuCode: l.skuCode, skuDescription: l.skuDescription, locationCode: l.locationCode, lpnNumber: l.lpnNumber })) });
    });
  }

  create(dto: CreateAdjustmentDto, opts: { source?: string; countId?: string; tx?: Tx } = {}) {
    assertWarehouseAccess(dto.warehouseId);
    const ctx = requireRequestContext();
    const storerId = resolveStorerScope(dto.storerId)!;
    return this.db.run(
      async (tx) => {
        const [reason] = await tx.select().from(reasonCodes).where(eq(reasonCodes.id, dto.reasonCodeId)).limit(1);
        if (!reason || !reason.active) throw new ValidationError('Invalid reason code');
        const adjNumber = await this.numbering.next('ADJ', tx, dto.warehouseId);
        const [adj] = await tx
          .insert(inventoryAdjustments)
          .values({ tenantId: ctx.tenantId!, warehouseId: dto.warehouseId, storerId, adjNumber, status: dto.submit ? 'PENDING_APPROVAL' : 'DRAFT', reasonCodeId: dto.reasonCodeId, source: opts.source ?? 'MANUAL', countId: opts.countId ?? null, notes: dto.notes, requestedById: ctx.userId! })
          .returning();
        for (const l of dto.lines) {
          const sku = await this.lookup.sku(tx, l.skuId);
          if (sku.storerId !== storerId) throw new DomainError(ErrorCodes.WRONG_SKU, `SKU ${sku.code} does not belong to the storer`);
          if (D(l.qtyDelta).isZero() && !l.toStatus) throw new ValidationError('A line must change quantity or status');
          const lpn = l.lpnNumber ? await this.lookup.lpnByNumber(tx, l.lpnNumber) : null;
          if (l.lpnNumber && !lpn) throw new NotFoundError('LPN', l.lpnNumber);
          await tx.insert(inventoryAdjustmentLines).values({
            tenantId: ctx.tenantId!,
            adjustmentId: adj.id,
            skuId: l.skuId,
            locationId: l.locationId,
            lpnId: l.lpnId ?? lpn?.id ?? null,
            lotNumber: l.lotNumber ?? '',
            expiryDate: l.expiryDate ?? null,
            fromStatus: l.fromStatus,
            toStatus: l.toStatus ?? null,
            qtyDelta: D(l.qtyDelta),
            serials: l.serials,
            notes: l.notes,
          });
        }
        await this.audit.log({ action: 'inventory.adjust.create', entityType: 'inventory_adjustment', entityId: adj.id, after: dto }, tx);
        return adj;
      },
      { tx: opts.tx },
    );
  }

  submit(id: string) {
    return this.db.run(async (tx) => {
      const [adj] = await tx.select().from(inventoryAdjustments).where(eq(inventoryAdjustments.id, id)).limit(1);
      if (!adj) throw new NotFoundError('Adjustment', id);
      assertWarehouseAccess(adj.warehouseId);
      if (adj.status !== 'DRAFT') throw new DomainError(ErrorCodes.INVALID_STATE, `Adjustment is ${adj.status}`);
      const [row] = await tx.update(inventoryAdjustments).set({ status: 'PENDING_APPROVAL' }).where(eq(inventoryAdjustments.id, id)).returning();
      await this.audit.log({ action: 'inventory.adjust.submit', entityType: 'inventory_adjustment', entityId: id }, tx);
      return row;
    });
  }

  /** Approve posts immediately (approval and posting are one step for the approver). */
  decide(id: string, dto: z.infer<typeof DecideAdjustmentSchema>) {
    const ctx = requireRequestContext();
    return this.db.run(async (tx) => {
      const [adj] = await tx.select().from(inventoryAdjustments).where(eq(inventoryAdjustments.id, id)).limit(1).for('update');
      if (!adj) throw new NotFoundError('Adjustment', id);
      assertWarehouseAccess(adj.warehouseId);
      if (adj.status !== 'PENDING_APPROVAL') throw new DomainError(ErrorCodes.INVALID_STATE, `Adjustment is ${adj.status}`);
      if (adj.requestedById === ctx.userId && !hasPermission(ctx.permissions, 'tenant.settings.manage')) {
        throw new ForbiddenError('You cannot approve your own adjustment', 'SELF_APPROVAL');
      }
      if (dto.decision === 'REJECT') {
        const [row] = await tx.update(inventoryAdjustments).set({ status: 'REJECTED', approvedById: ctx.userId, approvedAt: new Date(), rejectedReason: dto.notes }).where(eq(inventoryAdjustments.id, id)).returning();
        await this.audit.log({ action: 'inventory.adjust.reject', entityType: 'inventory_adjustment', entityId: id, after: dto }, tx);
        return row;
      }
      await this.postLines(tx, adj);
      const [row] = await tx.update(inventoryAdjustments).set({ status: 'POSTED', approvedById: ctx.userId, approvedAt: new Date(), postedAt: new Date() }).where(eq(inventoryAdjustments.id, id)).returning();
      await this.audit.log({ action: 'inventory.adjust.post', entityType: 'inventory_adjustment', entityId: id, after: dto }, tx);
      await this.events.publish('inventory.adjusted', { adjustmentId: id, adjNumber: adj.adjNumber, source: adj.source }, tx, { warehouseId: adj.warehouseId, storerId: adj.storerId });
      return row;
    });
  }

  /** Writes the ledger movements for every line. Used by decide() and by cycle-count variance posting. */
  async postLines(tx: Tx, adj: typeof inventoryAdjustments.$inferSelect) {
    const lines = await tx.select().from(inventoryAdjustmentLines).where(eq(inventoryAdjustmentLines.adjustmentId, adj.id));
    for (const l of lines) {
      const delta = D(l.qtyDelta);
      let movementId: string | null = null;
      if (l.toStatus && l.toStatus !== l.fromStatus) {
        // status change for the full quantity at that bucket (qtyDelta must be 0 for pure status change)
        if (!delta.isZero()) throw new ValidationError('Status-change lines must have qtyDelta = 0');
        const b = await this.ledger.getBalance(tx, { warehouseId: adj.warehouseId, storerId: adj.storerId, skuId: l.skuId, locationId: l.locationId, lpnId: l.lpnId, lotNumber: l.lotNumber, expiryDate: l.expiryDate, status: l.fromStatus }, true);
        if (!b || D(b.qtyOnHand).lte(0)) throw new DomainError(ErrorCodes.INSUFFICIENT_STOCK, 'No stock in the source status');
        const m = await this.ledger.post(tx, {
          type: 'STATUS_CHANGE', qty: b.qtyOnHand, warehouseId: adj.warehouseId, storerId: adj.storerId, skuId: l.skuId,
          from: { locationId: l.locationId, lpnId: l.lpnId, status: l.fromStatus }, to: { locationId: l.locationId, lpnId: l.lpnId, status: l.toStatus },
          lotNumber: l.lotNumber, expiryDate: l.expiryDate, reasonCodeId: adj.reasonCodeId, refType: 'ADJUSTMENT', refId: adj.id, serials: l.serials, firstReceivedAt: b.firstReceivedAt,
        });
        movementId = m.id;
      } else if (delta.gt(0)) {
        const m = await this.ledger.post(tx, {
          type: adj.source === 'COUNT' ? 'COUNT_ADJUST' : 'ADJUST_IN', qty: delta, warehouseId: adj.warehouseId, storerId: adj.storerId, skuId: l.skuId,
          to: { locationId: l.locationId, lpnId: l.lpnId, status: l.fromStatus }, lotNumber: l.lotNumber, expiryDate: l.expiryDate,
          reasonCodeId: adj.reasonCodeId, refType: 'ADJUSTMENT', refId: adj.id, serials: l.serials, skipSkuValidation: true,
        });
        movementId = m.id;
      } else {
        const m = await this.ledger.post(tx, {
          type: adj.source === 'COUNT' ? 'COUNT_ADJUST' : 'ADJUST_OUT', qty: delta.abs(), warehouseId: adj.warehouseId, storerId: adj.storerId, skuId: l.skuId,
          from: { locationId: l.locationId, lpnId: l.lpnId, status: l.fromStatus }, lotNumber: l.lotNumber, expiryDate: l.expiryDate,
          reasonCodeId: adj.reasonCodeId, refType: 'ADJUSTMENT', refId: adj.id, serials: l.serials,
        });
        movementId = m.id;
      }
      await tx.update(inventoryAdjustmentLines).set({ movementId }).where(eq(inventoryAdjustmentLines.id, l.id));
    }
  }

  cancel(id: string) {
    return this.db.run(async (tx) => {
      const [adj] = await tx.select().from(inventoryAdjustments).where(eq(inventoryAdjustments.id, id)).limit(1);
      if (!adj) throw new NotFoundError('Adjustment', id);
      assertWarehouseAccess(adj.warehouseId);
      if (adj.status === 'POSTED') throw new DomainError(ErrorCodes.INVALID_STATE, 'Posted adjustments cannot be cancelled; create a reversing adjustment');
      const [row] = await tx.update(inventoryAdjustments).set({ status: 'CANCELLED' }).where(eq(inventoryAdjustments.id, id)).returning();
      await this.audit.log({ action: 'inventory.adjust.cancel', entityType: 'inventory_adjustment', entityId: id }, tx);
      return row;
    });
  }
}
