import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core";
import { Decimal } from "decimal.js";
import type { Selectable, Transaction, Updateable } from "../generated/kysely-tailordb";
import { deriveReceiptStatus, deriveBillingStatus, type LineProgress } from "../lib/domain";
import {
  EmptyAmendmentChangesError,
  EmptyPurchaseOrderLinesError,
  PoNotFoundError,
  PoNotOrderedError,
  LineNotFoundError,
  ModifyQuantityBelowReceivedError,
  ModifyQuantityBelowBilledError,
  RemoveLineHasReceiptsError,
  RemoveLineHasBillsError,
  RemoveLineHasDownstreamReferencesError,
  ItemNotFoundError,
  InvalidQuantityError,
  InvalidUnitPriceError,
  ReceivingSiteNotFoundError,
  ReceivingSiteRequiredError,
} from "../lib/errors.generated";
import type {
  InventoryCommands,
  InventoryQueries,
  ItemManagementQueries,
  OrganizationQueries,
} from "../module";
import type { PurchaseOrderLineInput } from "./createPurchaseOrder";

export interface AmendPurchaseOrderHeaderPatch {
  orderDate?: Date;
  receivingSiteId?: string | null;
  externalSupplierOrderReference?: string | null;
}

export interface AmendPurchaseOrderLinePatch {
  quantity?: string;
  unitPrice?: string;
  receivingSiteId?: string | null;
  requiresPhysicalReceipt?: boolean;
}

export interface AmendPurchaseOrderLineEdit {
  lineId: string;
  linePatch: AmendPurchaseOrderLinePatch;
}

export interface AmendOrderedPurchaseOrderInput {
  id: string;
  reason?: string;
  headerPatch?: AmendPurchaseOrderHeaderPatch;
  addLines?: PurchaseOrderLineInput[];
  updateLines?: AmendPurchaseOrderLineEdit[];
  removeLineIds?: string[];
}

// The order's domain-owned state that invariants are checked over, minus DB-assigned fields.
type PurchaseOrderHeaderState = Omit<Selectable<"PurchaseOrder">, "id" | "createdAt" | "updatedAt">;

type PurchaseOrderLineState = Omit<
  Selectable<"PurchaseOrderLine">,
  "id" | "createdAt" | "updatedAt" | "purchaseOrderId"
>;

function checkPurchaseOrderInvariants(
  id: string,
  header: PurchaseOrderHeaderState,
  lines: PurchaseOrderLineState[],
) {
  if (lines.length === 0) {
    return new EmptyPurchaseOrderLinesError(id);
  }
  for (const line of lines) {
    if (new Decimal(line.quantity).lte(0)) {
      return new InvalidQuantityError(line.itemId);
    }
    if (new Decimal(line.unitPrice).lt(0)) {
      return new InvalidUnitPriceError(line.itemId);
    }
    if (line.requiresPhysicalReceipt && !line.receivingSiteId && !header.receivingSiteId) {
      return new ReceivingSiteRequiredError(line.itemId);
    }
  }
  return null;
}

// The revision's domain-owned state that invariants are checked over, minus DB-assigned fields.
type PurchaseOrderFieldChangeState = Omit<
  Selectable<"PurchaseOrderFieldChange">,
  "id" | "revisionId" | "createdAt" | "updatedAt"
>;

function checkRevisionInvariants(id: string, fieldChanges: PurchaseOrderFieldChangeState[]) {
  if (fieldChanges.length === 0) {
    return new EmptyAmendmentChangesError(id);
  }
  return null;
}

const LINE_AUDIT_EXCLUDED_FIELDS = new Set([
  "id",
  "purchaseOrderId",
  "itemSnapshotName",
  "itemSnapshotSku",
  "createdAt",
  "updatedAt",
]);

function serializeValue(value: unknown): string | null {
  if (value === null || value === undefined) return null;
  if (value instanceof Date) return value.toISOString();
  if (typeof value === "string") return value;
  if (typeof value === "boolean") return value ? "true" : "false";
  if (typeof value === "number" || typeof value === "bigint") return String(value);
  return JSON.stringify(value) ?? null;
}

export async function run<
  CF extends Record<string, unknown> = Record<string, unknown>,
  LCF extends Record<string, unknown> = Record<string, unknown>,
>(
  db: Transaction,
  input: Omit<AmendOrderedPurchaseOrderInput, "headerPatch" | "addLines" | "updateLines"> & {
    headerPatch?: AmendPurchaseOrderHeaderPatch & Partial<CF>;
    addLines?: (PurchaseOrderLineInput & LCF)[];
    updateLines?: { lineId: string; linePatch: AmendPurchaseOrderLinePatch & Partial<LCF> }[];
  },
  ctx: CommandContext,
  itemManagementQueries: Pick<ItemManagementQueries, "getItem">,
  inventoryCommands: Pick<
    InventoryCommands,
    "createInventorySupplyPlan" | "updateInventorySupplyPlan" | "closeInventorySupplyPlan"
  >,
  inventoryQueries: Pick<
    InventoryQueries,
    "getInventorySupplyPlan" | "listPurchaseReceiptEvidenceForMatching"
  >,
  organizationQueries: Pick<OrganizationQueries, "getSite">,
) {
  const { id, reason, headerPatch, addLines = [], updateLines = [], removeLineIds = [] } = input;

  // ===== 1. Load the current order and its lines =====
  // Lock the order row; only an ORDERED purchase order can be amended.
  const currentHeader = await db
    .selectFrom("PurchaseOrder")
    .selectAll()
    .where("id", "=", id)
    .forUpdate()
    .executeTakeFirst();
  if (!currentHeader) {
    return err(new PoNotFoundError(id));
  }
  if (currentHeader.orderStatus !== "ORDERED") {
    return err(new PoNotOrderedError(id));
  }

  // Index the current lines; reject any update or removal that targets a missing line.
  const currentLines = await db
    .selectFrom("PurchaseOrderLine")
    .selectAll()
    .where("purchaseOrderId", "=", id)
    .execute();
  const currentLineById = new Map(currentLines.map((line) => [line.id, line]));
  const linePatchById = new Map(updateLines.map((edit) => [edit.lineId, edit.linePatch]));
  for (const lineId of [...linePatchById.keys(), ...removeLineIds]) {
    if (!currentLineById.has(lineId)) {
      return err(new LineNotFoundError(lineId));
    }
  }

  // ===== 2. Validate the referenced master data =====
  // Each referenced entity is checked only where it is (re)chosen, and snapshotted when kept.
  // Items — added lines must reference an existing item; snapshot its name and SKU.
  const itemSnapshotByItemId = new Map<string, { name: string; sku: string }>();
  for (const line of addLines) {
    if (itemSnapshotByItemId.has(line.itemId)) {
      continue;
    }
    const { item } = (await itemManagementQueries.getItem(db, { id: line.itemId }, ctx)).value;
    if (!item) {
      return err(new ItemNotFoundError(line.itemId));
    }
    itemSnapshotByItemId.set(line.itemId, { name: item.name, sku: item.sku });
  }
  // Every added itemId was validated above, so this lookup never misses.
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  const itemSnapshotFor = (itemId: string) => itemSnapshotByItemId.get(itemId)!;

  // Receiving sites — every referenced site must exist.
  const siteIdsToValidate = new Set<string>();
  if (headerPatch?.receivingSiteId != null) {
    siteIdsToValidate.add(headerPatch.receivingSiteId);
  }
  for (const line of addLines) {
    if (line.receivingSiteId != null) {
      siteIdsToValidate.add(line.receivingSiteId);
    }
  }
  for (const { linePatch } of updateLines) {
    if (linePatch.receivingSiteId != null) {
      siteIdsToValidate.add(linePatch.receivingSiteId);
    }
  }
  for (const siteId of siteIdsToValidate) {
    const { site } = (await organizationQueries.getSite(db, { id: siteId }, ctx)).value;
    if (!site) {
      return err(new ReceivingSiteNotFoundError(siteId));
    }
  }

  // ===== 3. Enforce quantity and removal limits =====
  const referencedLineIds = [...updateLines.map((edit) => edit.lineId), ...removeLineIds];
  if (referencedLineIds.length > 0) {
    const receivedByLineId = new Map<string, Decimal>();
    const billedByLineId = new Map<string, Decimal>();
    const receiptEvidenceResult = await inventoryQueries.listPurchaseReceiptEvidenceForMatching(
      db,
      {
        purchaseOrderId: id,
        purchaseOrderLineIds: referencedLineIds,
        statuses: ["DRAFT", "POSTED"],
      },
      ctx,
    );
    for (const evidence of receiptEvidenceResult.value.receiptEvidence) {
      receivedByLineId.set(
        evidence.purchaseOrderLineId,
        (receivedByLineId.get(evidence.purchaseOrderLineId) ?? new Decimal(0)).plus(
          evidence.quantity,
        ),
      );
    }

    for (const line of currentLines) {
      if (!referencedLineIds.includes(line.id)) {
        continue;
      }
      billedByLineId.set(line.id, new Decimal(line.billedQuantity ?? "0"));
    }

    // A reduced quantity cannot drop below what is already received or billed.
    for (const { lineId, linePatch } of updateLines) {
      if (linePatch.quantity === undefined) {
        continue;
      }
      if (new Decimal(linePatch.quantity).lt(receivedByLineId.get(lineId) ?? new Decimal(0))) {
        return err(new ModifyQuantityBelowReceivedError(lineId));
      }
      if (new Decimal(linePatch.quantity).lt(billedByLineId.get(lineId) ?? new Decimal(0))) {
        return err(new ModifyQuantityBelowBilledError(lineId));
      }
    }
    // A line still tied to received or billed quantity cannot be removed.
    for (const lineId of removeLineIds) {
      if ((receivedByLineId.get(lineId) ?? new Decimal(0)).gt(0)) {
        return err(new RemoveLineHasReceiptsError(lineId));
      }
      if ((billedByLineId.get(lineId) ?? new Decimal(0)).gt(0)) {
        return err(new RemoveLineHasBillsError(lineId));
      }
    }
  }

  // A line cannot be removed while draft inventory receipt evidence still references it.
  if (removeLineIds.length > 0) {
    const draftReceiptResult = await inventoryQueries.listPurchaseReceiptEvidenceForMatching(
      db,
      { purchaseOrderId: id, purchaseOrderLineIds: removeLineIds, statuses: ["DRAFT"] },
      ctx,
    );
    const draftReceiptRef = draftReceiptResult.value.receiptEvidence[0];
    if (draftReceiptRef) {
      return err(
        new RemoveLineHasDownstreamReferencesError(String(draftReceiptRef.purchaseOrderLineId)),
      );
    }
  }

  // ===== 4. Recalculate receipt and billing status =====
  const remainingLineIds = currentLines
    .filter((line) => !removeLineIds.includes(line.id))
    .map((line) => line.id);
  const receivedByLineId = new Map<string, Decimal>();
  const billedByLineId = new Map<string, Decimal>();
  if (remainingLineIds.length > 0) {
    const postedReceiptResult = await inventoryQueries.listPurchaseReceiptEvidenceForMatching(
      db,
      { purchaseOrderId: id, purchaseOrderLineIds: remainingLineIds },
      ctx,
    );
    for (const evidence of postedReceiptResult.value.receiptEvidence) {
      receivedByLineId.set(
        evidence.purchaseOrderLineId,
        (receivedByLineId.get(evidence.purchaseOrderLineId) ?? new Decimal(0)).plus(
          evidence.quantity,
        ),
      );
    }
    for (const line of currentLines) {
      if (!remainingLineIds.includes(line.id)) {
        continue;
      }
      billedByLineId.set(line.id, new Decimal(line.billedQuantity ?? "0"));
    }
  }

  // ===== 5. Reconstitute the amended order and check its invariants =====
  const editedExistingLines = currentLines
    .filter((line) => !removeLineIds.includes(line.id))
    .map((line) => {
      const linePatch = linePatchById.get(line.id);
      if (linePatch === undefined) {
        return line;
      }
      return {
        ...line,
        quantity: linePatch.quantity === undefined ? line.quantity : linePatch.quantity,
        unitPrice: linePatch.unitPrice === undefined ? line.unitPrice : linePatch.unitPrice,
        receivingSiteId:
          linePatch.receivingSiteId === undefined
            ? line.receivingSiteId
            : linePatch.receivingSiteId,
        requiresPhysicalReceipt:
          linePatch.requiresPhysicalReceipt === undefined
            ? line.requiresPhysicalReceipt
            : linePatch.requiresPhysicalReceipt,
      };
    });
  const lineProgress: LineProgress[] = [
    ...editedExistingLines.map((line) => ({
      quantity: line.quantity,
      receivedQuantity: (receivedByLineId.get(line.id) ?? new Decimal(0)).toString(),
      billedQuantity: (billedByLineId.get(line.id) ?? new Decimal(0)).toString(),
    })),
    ...addLines.map((line) => ({
      quantity: line.quantity,
      receivedQuantity: "0",
      billedQuantity: "0",
    })),
  ];
  const mergedLines: PurchaseOrderLineState[] = [...editedExistingLines];
  for (const line of addLines) {
    const itemSnapshot = itemSnapshotFor(line.itemId);
    mergedLines.push({
      itemId: line.itemId,
      itemSnapshotName: itemSnapshot.name,
      itemSnapshotSku: itemSnapshot.sku,
      quantity: line.quantity,
      unitPrice: line.unitPrice,
      unitId: line.unitId,
      receivingSiteId: line.receivingSiteId ?? null,
      requiresPhysicalReceipt: line.requiresPhysicalReceipt ?? true,
      billedQuantity: null,
    });
  }

  const mergedHeader: PurchaseOrderHeaderState = {
    ...currentHeader,
    orderDate:
      headerPatch?.orderDate === undefined ? currentHeader.orderDate : headerPatch.orderDate,
    receivingSiteId:
      headerPatch?.receivingSiteId === undefined
        ? currentHeader.receivingSiteId
        : headerPatch.receivingSiteId,
    externalSupplierOrderReference:
      headerPatch?.externalSupplierOrderReference === undefined
        ? currentHeader.externalSupplierOrderReference
        : headerPatch.externalSupplierOrderReference,
    receiptStatus: deriveReceiptStatus(lineProgress),
    billingStatus: deriveBillingStatus(lineProgress),
  };
  const violation = checkPurchaseOrderInvariants(id, mergedHeader, mergedLines);
  if (violation) {
    return err(violation);
  }

  // ===== 6. Apply the order changes =====
  // Header — write the patch and store the recalculated status.
  const headerUpdates: Updateable<"PurchaseOrder"> = { ...headerPatch };
  const purchaseOrder = await db
    .updateTable("PurchaseOrder")
    .set({
      ...headerUpdates,
      receiptStatus: mergedHeader.receiptStatus,
      billingStatus: mergedHeader.billingStatus,
    })
    .where("id", "=", id)
    .returningAll()
    .executeTakeFirstOrThrow();

  // Updated lines — patch in place so their ids and createdAt stay stable.
  for (const { lineId, linePatch } of updateLines) {
    const lineUpdates: Updateable<"PurchaseOrderLine"> = { ...linePatch };
    // An empty patch touches nothing (kysely cannot compile a SET with no columns).
    if (Object.values(lineUpdates).some((value) => value !== undefined)) {
      await db.updateTable("PurchaseOrderLine").set(lineUpdates).where("id", "=", lineId).execute();
    }
  }

  // Removed lines — delete.
  if (removeLineIds.length > 0) {
    await db.deleteFrom("PurchaseOrderLine").where("id", "in", removeLineIds).execute();
  }

  // Added lines — insert with the resolved item snapshots.
  let addedLines: Selectable<"PurchaseOrderLine">[] = [];
  if (addLines.length > 0) {
    addedLines = await db
      .insertInto("PurchaseOrderLine")
      .values(
        addLines.map((line) => {
          const {
            itemId,
            quantity,
            unitPrice,
            unitId,
            receivingSiteId,
            requiresPhysicalReceipt,
            ...lineCustomFields
          } = line;
          const itemSnapshot = itemSnapshotFor(itemId);
          return {
            // Custom fields first so command-set columns (purchaseOrderId, snapshots) always win.
            ...(lineCustomFields as Record<string, unknown>),
            purchaseOrderId: id,
            itemId,
            itemSnapshotName: itemSnapshot.name,
            itemSnapshotSku: itemSnapshot.sku,
            quantity,
            unitPrice,
            unitId,
            receivingSiteId,
            requiresPhysicalReceipt: requiresPhysicalReceipt ?? true,
          };
        }),
      )
      .returningAll()
      .execute();
  }
  const finalLines = [...editedExistingLines, ...addedLines];

  // ===== 7. Reconstitute the revision and check its invariants =====
  const fieldChanges: PurchaseOrderFieldChangeState[] = [];

  // Header fields — one row per field whose value actually changed.
  for (const [fieldName, value] of Object.entries(headerPatch ?? {})) {
    if (value === undefined) {
      continue;
    }
    const oldValue = serializeValue(
      (currentHeader as unknown as Record<string, unknown>)[fieldName],
    );
    const newValue = serializeValue(value);
    if (oldValue === newValue) {
      continue;
    }
    fieldChanges.push({
      recordType: "HEADER",
      recordId: id,
      fieldName,
      changeKind: "MODIFIED",
      oldValue,
      newValue,
    });
  }

  // Updated-line fields (standard + extension) — one row per field the patch actually changes.
  for (const { lineId, linePatch } of updateLines) {
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    const before = currentLineById.get(lineId)!;
    for (const [fieldName, value] of Object.entries(linePatch)) {
      if (value === undefined) {
        continue;
      }
      const oldValue = serializeValue((before as unknown as Record<string, unknown>)[fieldName]);
      const newValue = serializeValue(value);
      if (oldValue === newValue) {
        continue;
      }
      fieldChanges.push({
        recordType: "LINE",
        recordId: lineId,
        fieldName,
        changeKind: "MODIFIED",
        oldValue,
        newValue,
      });
    }
  }

  // Removed-line fields — record each non-null field as cleared.
  for (const lineId of removeLineIds) {
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    const line = currentLineById.get(lineId)!;
    for (const [fieldName, oldRaw] of Object.entries(line as unknown as Record<string, unknown>)) {
      if (LINE_AUDIT_EXCLUDED_FIELDS.has(fieldName)) {
        continue;
      }
      const oldValue = serializeValue(oldRaw);
      if (oldValue === null) {
        continue;
      }
      fieldChanges.push({
        recordType: "LINE",
        recordId: line.id,
        fieldName,
        changeKind: "REMOVED",
        oldValue,
        newValue: null,
      });
    }
  }

  // Added-line fields — record each non-null field as added.
  for (const line of addedLines) {
    for (const [fieldName, newRaw] of Object.entries(line as unknown as Record<string, unknown>)) {
      if (LINE_AUDIT_EXCLUDED_FIELDS.has(fieldName)) {
        continue;
      }
      const newValue = serializeValue(newRaw);
      if (newValue === null) {
        continue;
      }
      fieldChanges.push({
        recordType: "LINE",
        recordId: line.id,
        fieldName,
        changeKind: "ADDED",
        oldValue: null,
        newValue,
      });
    }
  }

  const revisionViolation = checkRevisionInvariants(id, fieldChanges);
  if (revisionViolation) {
    return err(revisionViolation);
  }

  // ===== 8. Apply the revision =====
  const lastRevision = await db
    .selectFrom("PurchaseOrderRevision")
    .select("revisionNumber")
    .where("purchaseOrderId", "=", id)
    .orderBy("revisionNumber", "desc")
    .limit(1)
    .executeTakeFirst();
  const revision = await db
    .insertInto("PurchaseOrderRevision")
    .values({
      purchaseOrderId: id,
      revisionNumber: (lastRevision?.revisionNumber ?? 0) + 1,
      reason,
      amendedByUserId: ctx.actorId,
    })
    .returningAll()
    .executeTakeFirstOrThrow();

  await db
    .insertInto("PurchaseOrderFieldChange")
    .values(fieldChanges.map((fc) => ({ ...fc, revisionId: revision.id })))
    .execute();

  // ===== 9. Reconcile inventory supply plans =====
  // Removed lines — close their open supply plans.
  if (removeLineIds.length > 0) {
    const closeResult = await inventoryCommands.closeInventorySupplyPlan(
      db,
      {
        target: "SOURCE_LINES",
        sourceType: "PURCHASE_ORDER",
        sourceLineIds: removeLineIds,
      },
      ctx,
    );
    if (!closeResult.ok) {
      return closeResult;
    }
  }

  // A header receiving-site or order-date change propagates to every line that inherits it.
  const headerReceivingSiteChanged = mergedHeader.receivingSiteId !== currentHeader.receivingSiteId;
  const headerOrderDateChanged =
    serializeValue(mergedHeader.orderDate) !== serializeValue(currentHeader.orderDate);
  const reconcileEveryLine = headerReceivingSiteChanged || headerOrderDateChanged;
  const addedLineIds = new Set(addedLines.map((line) => line.id));
  const linesToReconcile = new Map<string, Selectable<"PurchaseOrderLine">>();
  for (const line of finalLines) {
    if (reconcileEveryLine || linePatchById.has(line.id) || addedLineIds.has(line.id)) {
      linesToReconcile.set(line.id, line);
    }
  }

  const supplyPlansToCreate: Array<{
    sourceLineId: string;
    itemId: string;
    siteId: string;
    expectedQuantity: string;
    unitId: string;
    expectedDate: Date;
  }> = [];
  for (const line of linesToReconcile.values()) {
    const supplyPlanResult = await inventoryQueries.getInventorySupplyPlan(
      db,
      { sourceType: "PURCHASE_ORDER", sourceLineId: line.id },
      ctx,
    );
    const existingSupplyPlan = supplyPlanResult.value.supplyPlan;
    const siteId = line.receivingSiteId ?? mergedHeader.receivingSiteId;
    if (!line.requiresPhysicalReceipt || !siteId) {
      if (existingSupplyPlan) {
        const updateResult = await inventoryCommands.updateInventorySupplyPlan(
          db,
          { id: existingSupplyPlan.id, expectedQuantity: "0" },
          ctx,
        );
        if (!updateResult.ok) {
          return updateResult;
        }
      }
      continue;
    }

    if (existingSupplyPlan) {
      const updateResult = await inventoryCommands.updateInventorySupplyPlan(
        db,
        {
          id: existingSupplyPlan.id,
          sourceType: "PURCHASE_ORDER",
          sourceId: id,
          sourceLineId: line.id,
          itemId: line.itemId,
          siteId,
          expectedQuantity: line.quantity,
          unitId: line.unitId,
          expectedDate: mergedHeader.orderDate,
        },
        ctx,
      );
      if (!updateResult.ok) {
        return updateResult;
      }
      continue;
    }

    supplyPlansToCreate.push({
      sourceLineId: line.id,
      itemId: line.itemId,
      siteId,
      expectedQuantity: line.quantity,
      unitId: line.unitId,
      expectedDate: mergedHeader.orderDate,
    });
  }

  if (supplyPlansToCreate.length > 0) {
    const createResult = await inventoryCommands.createInventorySupplyPlan(
      db,
      { sourceType: "PURCHASE_ORDER", sourceId: id, supplyPlans: supplyPlansToCreate },
      ctx,
    );
    if (!createResult.ok) {
      return createResult;
    }
  }

  return ok({ purchaseOrder });
}
