import prisma from "../config/database";
import { Prisma } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
import { applyPattern, getNextSequence } from "./numbering.service";

type TxClient = Omit<
  PrismaClient,
  "$connect" | "$disconnect" | "$on" | "$transaction" | "$extends"
>;

// ---------------------------------------------------------------------------
// Concurrency helpers
// ---------------------------------------------------------------------------

/**
 * Lock a single parent row (receipt / issue / inventory session / item) for the
 * remainder of the current transaction. Two concurrent calls land in serial
 * order: the second one waits for the first to commit and then re-reads the
 * row, seeing the updated status.
 *
 * The row is locked with SELECT ... FOR UPDATE — releasing on commit/rollback.
 * Always call this at the top of a `confirm*` / `cancel*` transaction,
 * BEFORE the read-then-write status check, so the check-then-act window is
 * closed.
 *
 * If the row does not exist, the caller (e.g. the `findUnique` that follows)
 * will see `null` and return a 404 — the FOR UPDATE returns no rows in that
 * case (no lock taken, but also no work to do).
 */
async function lockParentRow(
  tx: TxClient,
  table:
    | "sklad_receipts"
    | "sklad_issues"
    | "sklad_inventory_sessions"
    | "sklad_items",
  id: number,
): Promise<void> {
  // Table name is whitelisted above; no user input. `Prisma.raw` lets us splice
  // the identifier into the query while `${id}` parameterizes the value.
  // Use `$queryRaw` (not `$executeRaw`) for a bare `SELECT … FOR UPDATE`: it
  // executes the SELECT and reliably acquires/holds the row lock for the tx —
  // the same form attendance.service.lockUserRow uses.
  await tx.$queryRaw`SELECT id FROM ${Prisma.raw(table)} WHERE id = ${id} FOR UPDATE`;
}

/**
 * Lock a set of child rows (items / batches) FOR UPDATE within the current
 * transaction. Always locks in ASCENDING id order — locking in a deterministic
 * order across all callers is what prevents deadlocks when two transactions
 * touch an overlapping set of rows (e.g. two issues consuming the same batch).
 *
 * Uses the same SELECT ... FOR UPDATE pattern as `lockParentRow`. Ids are
 * de-duplicated and sorted before the lock. A missing id simply matches no row
 * (no lock taken); the caller's subsequent findUnique surfaces the 404.
 */
async function lockRowsForUpdate(
  tx: TxClient,
  table: "sklad_items" | "sklad_batches",
  ids: number[],
): Promise<void> {
  const uniqueSorted = Array.from(
    new Set(ids.filter((id): id is number => typeof id === "number")),
  ).sort((a, b) => a - b);
  for (const id of uniqueSorted) {
    // Table name is whitelisted above; no user input. `Prisma.raw` splices the
    // identifier; `${id}` is parameterized. `$queryRaw` reliably holds the lock.
    await tx.$queryRaw`SELECT id FROM ${Prisma.raw(table)} WHERE id = ${id} FOR UPDATE`;
  }
}

// ---------------------------------------------------------------------------
// Number sequence helpers for warehouse documents
// ---------------------------------------------------------------------------

// Default prefixes and pattern (backward compatible with existing hardcoded format)
const DEFAULT_RECEIPT_PREFIX = "PRI";
const DEFAULT_ISSUE_PREFIX = "VYD";
const DEFAULT_INVENTORY_PREFIX = "INV";
const DEFAULT_WAREHOUSE_PATTERN = "{PREFIX}-{YYYY}-{NNN}";

/**
 * Generate a formatted warehouse document number using configurable pattern.
 * Reads prefix and pattern from company_settings, falls back to defaults.
 */
async function generateWarehouseNumber(
  type: string,
  tx: TxClient,
): Promise<string> {
  const year = new Date().getFullYear();
  const settings = await prisma.company_settings.findFirst({
    select: {
      warehouse_receipt_prefix: true,
      warehouse_receipt_number_pattern: true,
      warehouse_issue_prefix: true,
      warehouse_issue_number_pattern: true,
      warehouse_inventory_prefix: true,
      warehouse_inventory_number_pattern: true,
    },
  });

  let prefix: string;
  let pattern: string;

  switch (type) {
    case "warehouse_receipt":
      prefix = settings?.warehouse_receipt_prefix || DEFAULT_RECEIPT_PREFIX;
      pattern =
        settings?.warehouse_receipt_number_pattern || DEFAULT_WAREHOUSE_PATTERN;
      break;
    case "warehouse_issue":
      prefix = settings?.warehouse_issue_prefix || DEFAULT_ISSUE_PREFIX;
      pattern =
        settings?.warehouse_issue_number_pattern || DEFAULT_WAREHOUSE_PATTERN;
      break;
    case "warehouse_inventory":
      prefix = settings?.warehouse_inventory_prefix || DEFAULT_INVENTORY_PREFIX;
      pattern =
        settings?.warehouse_inventory_number_pattern ||
        DEFAULT_WAREHOUSE_PATTERN;
      break;
    default:
      prefix = "WH";
      pattern = DEFAULT_WAREHOUSE_PATTERN;
  }

  const seq = await getNextSequence(type, year, tx);
  return applyPattern(pattern, { year, prefix, code: "", seq });
}

// ---------------------------------------------------------------------------
// Available Quantity
// ---------------------------------------------------------------------------

/**
 * Calculates total stock (sum of unconsumed batch quantities) minus active
 * reservation quantities for a given item.
 */
export async function getItemAvailableQty(itemId: number): Promise<number> {
  const [batchResult, reservationResult] = await Promise.all([
    prisma.sklad_batches.aggregate({
      _sum: { quantity: true },
      where: { item_id: itemId, is_consumed: false },
    }),
    prisma.sklad_reservations.aggregate({
      _sum: { remaining_qty: true },
      where: { item_id: itemId, status: "ACTIVE" },
    }),
  ]);

  const totalStock = Number(batchResult._sum.quantity ?? 0);
  const reservedQty = Number(reservationResult._sum.remaining_qty ?? 0);
  return totalStock - reservedQty;
}

/**
 * Transactional version of getItemAvailableQty. Use inside a $transaction
 * when reading available stock after locking the item row — non-tx reads
 * can otherwise miss another transaction's uncommitted writes.
 */
async function getItemAvailableQtyTx(
  itemId: number,
  tx: TxClient,
): Promise<number> {
  const [batchResult, reservationResult] = await Promise.all([
    tx.sklad_batches.aggregate({
      _sum: { quantity: true },
      where: { item_id: itemId, is_consumed: false },
    }),
    tx.sklad_reservations.aggregate({
      _sum: { remaining_qty: true },
      where: { item_id: itemId, status: "ACTIVE" },
    }),
  ]);

  const totalStock = Number(batchResult._sum.quantity ?? 0);
  const reservedQty = Number(reservationResult._sum.remaining_qty ?? 0);
  return totalStock - reservedQty;
}

// ---------------------------------------------------------------------------
// FIFO Batch Selection
// ---------------------------------------------------------------------------

/**
 * Selects oldest unconsumed batches for an item using FIFO.
 * If one batch doesn't have enough, it spans multiple batches.
 * Throws if insufficient stock.
 */
export async function selectFifoBatches(
  itemId: number,
  requestedQty: number,
  tx?: TxClient,
): Promise<Array<{ batchId: number; qty: number }>> {
  const client = tx ?? prisma;
  // Deterministic FIFO: oldest received first, then by id to break ties when
  // two batches share the same received_at (which is truncated to seconds, so
  // ties are common in a single transaction). Without the id tie-break the
  // order is undefined and FIFO selection is non-reproducible.
  const batches = await client.sklad_batches.findMany({
    where: { item_id: itemId, is_consumed: false, quantity: { gt: 0 } },
    orderBy: [{ received_at: "asc" }, { id: "asc" }],
    select: { id: true, quantity: true },
  });

  const result: Array<{ batchId: number; qty: number }> = [];
  let remaining = requestedQty;

  for (const batch of batches) {
    if (remaining <= 0) break;
    const batchQty = Number(batch.quantity);
    const take = Math.min(batchQty, remaining);
    result.push({ batchId: batch.id, qty: take });
    remaining -= take;
  }

  if (remaining > 0) {
    throw new Error("Nedostatečné množství na skladě");
  }

  return result;
}

// ---------------------------------------------------------------------------
// Item Total Stock
// ---------------------------------------------------------------------------

/**
 * Sum of unconsumed batch quantities for an item.
 */
export async function getItemTotalStock(itemId: number): Promise<number> {
  const result = await prisma.sklad_batches.aggregate({
    _sum: { quantity: true },
    where: { item_id: itemId, is_consumed: false },
  });
  return Number(result._sum.quantity ?? 0);
}

// ---------------------------------------------------------------------------
// Item Stock Value
// ---------------------------------------------------------------------------

/**
 * Sum of (quantity * unit_price) for unconsumed batches.
 */
export async function getItemStockValue(itemId: number): Promise<number> {
  const batches = await prisma.sklad_batches.findMany({
    where: { item_id: itemId, is_consumed: false },
    select: { quantity: true, unit_price: true },
  });

  return batches.reduce((sum, b) => {
    return sum + Number(b.quantity) * Number(b.unit_price);
  }, 0);
}

// ---------------------------------------------------------------------------
// Below Minimum Items
// ---------------------------------------------------------------------------

/**
 * Returns items where current stock is below their min_quantity threshold.
 */
export async function getBelowMinimumItems() {
  const items = await prisma.sklad_items.findMany({
    where: {
      is_active: true,
      min_quantity: { not: null },
    },
    include: {
      category: { select: { id: true, name: true } },
    },
  });

  if (items.length === 0) return [];

  // Single grouped aggregate over batches instead of one getItemTotalStock()
  // call per item (the old N+1). Sum unconsumed batch quantities per item_id
  // for exactly the items we care about, then look the totals up by id.
  const itemIds = items.map((item) => item.id);
  const stockByItem = await prisma.sklad_batches.groupBy({
    by: ["item_id"],
    where: { item_id: { in: itemIds }, is_consumed: false },
    _sum: { quantity: true },
  });
  const stockMap = new Map<number, number>();
  for (const row of stockByItem) {
    stockMap.set(row.item_id, Number(row._sum.quantity ?? 0));
  }

  const results = [];
  for (const item of items) {
    const totalStock = stockMap.get(item.id) ?? 0;
    const minQty = Number(item.min_quantity ?? 0);
    if (totalStock < minQty) {
      results.push({
        id: item.id,
        item_number: item.item_number,
        name: item.name,
        unit: item.unit,
        min_quantity: minQty,
        current_stock: totalStock,
        category: item.category,
      });
    }
  }

  return results;
}

// ---------------------------------------------------------------------------
// Receipt Confirm
// ---------------------------------------------------------------------------

/**
 * Confirms a warehouse receipt:
 * 1. Validate status is DRAFT
 * 2. Generate receipt_number via number_sequences
 * 3. For each receipt line: create sklad_batches row, upsert sklad_item_locations
 * 4. Update receipt status to CONFIRMED
 */
export async function confirmReceipt(
  receiptId: number,
  authUserId: number,
): Promise<
  | { data: { id: number; receipt_number: string } }
  | { error: string; status: number }
> {
  return prisma.$transaction(async (tx) => {
    // Lock the receipt row first so a concurrent confirm/cancel on the same
    // receipt serializes behind us. The status check below is now safe.
    await lockParentRow(tx, "sklad_receipts", receiptId);

    const receipt = await tx.sklad_receipts.findUnique({
      where: { id: receiptId },
      include: { items: true },
    });

    if (!receipt) {
      return { error: "Příjem nebyl nalezen", status: 404 };
    }

    if (receipt.status !== "DRAFT") {
      return { error: "Příjem není ve stavu DRAFT", status: 400 };
    }

    const receiptNumber = await generateWarehouseNumber(
      "warehouse_receipt",
      tx,
    );

    for (const item of receipt.items) {
      // Create batch for this receipt line
      await tx.sklad_batches.create({
        data: {
          item_id: item.item_id,
          receipt_line_id: item.id,
          quantity: item.quantity,
          original_qty: item.quantity,
          unit_price: item.unit_price,
          received_at: new Date(),
          is_consumed: false,
        },
      });

      // Upsert item location if location_id is specified
      if (item.location_id != null) {
        await tx.sklad_item_locations.upsert({
          where: {
            item_id_location_id: {
              item_id: item.item_id,
              location_id: item.location_id,
            },
          },
          create: {
            item_id: item.item_id,
            location_id: item.location_id,
            quantity: item.quantity,
          },
          update: {
            quantity: {
              increment: item.quantity,
            },
          },
        });
      }
    }

    // Update receipt status and number
    await tx.sklad_receipts.update({
      where: { id: receiptId },
      data: {
        status: "CONFIRMED",
        receipt_number: receiptNumber,
        received_by: authUserId,
        modified_at: new Date(),
      },
    });

    return { data: { id: receiptId, receipt_number: receiptNumber } };
  });
}

// ---------------------------------------------------------------------------
// Receipt Cancel
// ---------------------------------------------------------------------------

/**
 * Cancel a receipt:
 * - If DRAFT: just set CANCELLED.
 * - If CONFIRMED (storno): validate no batch consumed, reverse item_locations,
 *   delete batches, set CANCELLED.
 */
export async function cancelReceipt(
  receiptId: number,
): Promise<{ data: { id: number } } | { error: string; status: number }> {
  return prisma.$transaction(async (tx) => {
    // Lock the receipt row so a concurrent confirm/cancel on the same receipt
    // cannot race with us. The status check is now authoritative.
    await lockParentRow(tx, "sklad_receipts", receiptId);

    const receipt = await tx.sklad_receipts.findUnique({
      where: { id: receiptId },
      include: { items: { include: { batch: true } } },
    });

    if (!receipt) {
      return { error: "Příjem nebyl nalezen", status: 404 };
    }

    if (receipt.status === "CANCELLED") {
      return { error: "Příjem je již zrušen", status: 400 };
    }

    if (receipt.status === "DRAFT") {
      await tx.sklad_receipts.update({
        where: { id: receiptId },
        data: { status: "CANCELLED", modified_at: new Date() },
      });
      return { data: { id: receiptId } };
    }

    // CONFIRMED -> CANCELLED (storno)
    // Validate no batch has been consumed
    for (const item of receipt.items) {
      if (item.batch) {
        const batchData = await tx.sklad_batches.findUnique({
          where: { id: item.batch.id },
        });
        if (batchData && batchData.is_consumed) {
          return {
            error: "Příjem nelze zrušit - některé šarže byly již spotřebovány",
            status: 400,
          };
        }
        // Also check if quantity has been partially consumed (original_qty != quantity)
        if (
          batchData &&
          Number(batchData.quantity) < Number(batchData.original_qty)
        ) {
          return {
            error:
              "Příjem nelze zrušit - některé šarže byly částečně spotřebovány",
            status: 400,
          };
        }
      }
    }

    // Guard against FK violation: sklad_batches cannot be deleted if any
    // ACTIVE issue still references them. CANCELLED issues are safe to
    // ignore — cancelIssue() already restored the batch quantity and reset
    // is_consumed=false, so the issue_lines are audit-trail-only and no
    // longer represent live consumption. The is_consumed / quantity check
    // above doesn't see issue_lines at all, so we need this separate check.
    // A DRAFT issue is still treated as blocking — it has chosen the batch
    // and may be confirmed later, so deleting the batch underneath it would
    // orphan the issue.
    const batchIds = receipt.items
      .map((item) => item.batch?.id)
      .filter((id): id is number => typeof id === "number");
    if (batchIds.length > 0) {
      const referencingIssueLines = await tx.sklad_issue_lines.count({
        where: {
          batch_id: { in: batchIds },
          issue: { status: { not: "CANCELLED" } },
        },
      });
      if (referencingIssueLines > 0) {
        return {
          error:
            "Příjem nelze zrušit - některé šarže jsou použity v aktivních výdejích. Nejdříve zrušte nebo smažte příslušné výdeje.",
          status: 409,
        };
      }
    }

    // Reverse item_locations and delete batches
    for (const item of receipt.items) {
      if (item.location_id != null) {
        // Decrement location quantity (or delete if it would go to zero/below)
        const loc = await tx.sklad_item_locations.findUnique({
          where: {
            item_id_location_id: {
              item_id: item.item_id,
              location_id: item.location_id,
            },
          },
        });

        if (loc) {
          const newQty = Number(loc.quantity) - Number(item.quantity);
          if (newQty <= 0) {
            await tx.sklad_item_locations.delete({
              where: { id: loc.id },
            });
          } else {
            await tx.sklad_item_locations.update({
              where: { id: loc.id },
              data: { quantity: newQty },
            });
          }
        }
      }

      // Delete the batch (it's a 1:1 with receipt_line via @unique)
      if (item.batch) {
        await tx.sklad_batches.delete({
          where: { id: item.batch.id },
        });
      }
    }

    await tx.sklad_receipts.update({
      where: { id: receiptId },
      data: { status: "CANCELLED", modified_at: new Date() },
    });

    return { data: { id: receiptId } };
  });
}

// ---------------------------------------------------------------------------
// Issue Confirm
// ---------------------------------------------------------------------------

/**
 * Confirms a warehouse issue:
 * 1. Validate status is DRAFT
 * 2. Validate each line's batch has enough quantity
 * 3. Generate issue_number via number_sequences
 * 4. For each line: decrement batch quantity (set is_consumed if 0),
 *    decrement item_locations, fulfill reservation if linked
 * 5. Update status to CONFIRMED
 */
export async function confirmIssue(
  issueId: number,
  authUserId: number,
): Promise<
  | { data: { id: number; issue_number: string } }
  | { error: string; status: number }
> {
  return prisma.$transaction(async (tx) => {
    // Lock the issue row first so a concurrent confirm/cancel on the same
    // issue serializes behind us. The status check, batch-quantity validation,
    // and decrement below are now safe from a double-confirm race that would
    // otherwise drive batch quantities negative.
    await lockParentRow(tx, "sklad_issues", issueId);

    const issue = await tx.sklad_issues.findUnique({
      where: { id: issueId },
      include: { items: true },
    });

    if (!issue) {
      return { error: "Výdej nebyl nalezen", status: 404 };
    }

    if (issue.status !== "DRAFT") {
      return { error: "Výdej není ve stavu DRAFT", status: 400 };
    }

    // Lock every affected item AND batch row FOR UPDATE (in ascending id order)
    // BEFORE the read-modify-write on batch.quantity below. Without this, two
    // issues consuming the same batch could both read the same quantity, both
    // pass the validation, and both decrement — a lost update that drives the
    // batch quantity negative / double-issues stock. Locking the issue row
    // alone does not serialize two DIFFERENT issues that share a batch.
    await lockRowsForUpdate(
      tx,
      "sklad_items",
      issue.items.map((item) => item.item_id),
    );
    await lockRowsForUpdate(
      tx,
      "sklad_batches",
      issue.items.map((item) => item.batch_id),
    );

    // Validate each line's batch has enough quantity
    for (const item of issue.items) {
      const batch = await tx.sklad_batches.findUnique({
        where: { id: item.batch_id },
      });
      if (!batch) {
        return {
          error: `Šarže ID ${item.batch_id} nebyla nalezena`,
          status: 404,
        };
      }
      // The batch must belong to the same item as the line — a stale/forged
      // batch_id pointing at a different item would otherwise decrement the
      // wrong item's stock.
      if (batch.item_id !== item.item_id) {
        return {
          error: `Šarže ID ${item.batch_id} nepatří k položce ID ${item.item_id}`,
          status: 400,
        };
      }
      if (batch.is_consumed) {
        return {
          error: `Šarže ID ${item.batch_id} je již spotřebována`,
          status: 400,
        };
      }
      if (Number(batch.quantity) < Number(item.quantity)) {
        return {
          error: `Šarže ID ${item.batch_id} nemá dostatečné množství`,
          status: 400,
        };
      }
    }

    // Re-validate reservation rules (reservation may have been cancelled between draft and confirm)
    const reservationValidation = await validateIssueReservationRules(
      issue.items.map((item) => ({
        item_id: item.item_id,
        quantity: Number(item.quantity),
        reservation_id: item.reservation_id,
      })),
      issue.project_id,
      tx,
    );
    if ("error" in reservationValidation) {
      return {
        error: reservationValidation.error,
        status: reservationValidation.status,
      };
    }

    const issueNumber = await generateWarehouseNumber("warehouse_issue", tx);

    // Process each issue line
    for (const item of issue.items) {
      const itemQty = Number(item.quantity);

      // Decrement batch quantity
      const batch = await tx.sklad_batches.findUnique({
        where: { id: item.batch_id },
      });
      if (!batch) continue; // already validated above

      const newBatchQty = Number(batch.quantity) - itemQty;
      await tx.sklad_batches.update({
        where: { id: item.batch_id },
        data: {
          quantity: newBatchQty,
          is_consumed: newBatchQty <= 0,
        },
      });

      // Decrement item_locations if location_id is specified
      if (item.location_id != null) {
        const loc = await tx.sklad_item_locations.findUnique({
          where: {
            item_id_location_id: {
              item_id: item.item_id,
              location_id: item.location_id,
            },
          },
        });

        if (loc) {
          const newLocQty = Number(loc.quantity) - itemQty;
          if (newLocQty <= 0) {
            await tx.sklad_item_locations.delete({
              where: { id: loc.id },
            });
          } else {
            await tx.sklad_item_locations.update({
              where: { id: loc.id },
              data: { quantity: newLocQty },
            });
          }
        }
      }

      // Fulfill reservation if linked
      if (item.reservation_id != null) {
        const reservation = await tx.sklad_reservations.findUnique({
          where: { id: item.reservation_id },
        });
        if (reservation) {
          const newRemaining = Number(reservation.remaining_qty) - itemQty;
          await tx.sklad_reservations.update({
            where: { id: item.reservation_id },
            data: {
              remaining_qty: Math.max(0, newRemaining),
              status: newRemaining <= 0 ? "FULFILLED" : reservation.status,
              modified_at: new Date(),
            },
          });
        }
      }
    }

    // Update issue status and number
    await tx.sklad_issues.update({
      where: { id: issueId },
      data: {
        status: "CONFIRMED",
        issue_number: issueNumber,
        issued_by: authUserId,
        modified_at: new Date(),
      },
    });

    return { data: { id: issueId, issue_number: issueNumber } };
  });
}

// ---------------------------------------------------------------------------
// Issue Cancel
// ---------------------------------------------------------------------------

/**
 * Cancel a confirmed issue: restore batch quantities, restore item_locations,
 * restore reservations.
 */
export async function cancelIssue(
  issueId: number,
): Promise<{ data: { id: number } } | { error: string; status: number }> {
  return prisma.$transaction(async (tx) => {
    // Lock the issue row so a concurrent confirm/cancel on the same issue
    // cannot race with us. Without this, a parallel confirm could restore
    // batches that the original confirm already decremented (or vice versa).
    await lockParentRow(tx, "sklad_issues", issueId);

    const issue = await tx.sklad_issues.findUnique({
      where: { id: issueId },
      include: { items: true },
    });

    if (!issue) {
      return { error: "Výdej nebyl nalezen", status: 404 };
    }

    if (issue.status === "CANCELLED") {
      return { error: "Výdej je již zrušen", status: 400 };
    }

    if (issue.status === "DRAFT") {
      await tx.sklad_issues.update({
        where: { id: issueId },
        data: { status: "CANCELLED", modified_at: new Date() },
      });
      return { data: { id: issueId } };
    }

    // CONFIRMED -> CANCELLED: restore everything.
    // Lock the affected item rows then batch rows FOR UPDATE in ascending id
    // order — the SAME discipline confirmIssue/confirmInventorySession use — so
    // a concurrent confirm consuming a shared batch serialises on the item lock
    // first and the two paths cannot deadlock on an inverted batch-lock order.
    await lockRowsForUpdate(
      tx,
      "sklad_items",
      issue.items.map((i) => i.item_id),
    );
    await lockRowsForUpdate(
      tx,
      "sklad_batches",
      issue.items.map((i) => i.batch_id),
    );

    for (const item of issue.items) {
      const itemQty = Number(item.quantity);

      // Restore batch quantity
      const batch = await tx.sklad_batches.findUnique({
        where: { id: item.batch_id },
      });
      if (batch) {
        const restoredQty = Number(batch.quantity) + itemQty;
        await tx.sklad_batches.update({
          where: { id: item.batch_id },
          data: {
            quantity: restoredQty,
            is_consumed: false,
          },
        });
      }

      // Restore item_locations
      if (item.location_id != null) {
        await tx.sklad_item_locations.upsert({
          where: {
            item_id_location_id: {
              item_id: item.item_id,
              location_id: item.location_id,
            },
          },
          create: {
            item_id: item.item_id,
            location_id: item.location_id,
            quantity: itemQty,
          },
          update: {
            quantity: {
              increment: itemQty,
            },
          },
        });
      }

      // Restore reservation
      if (item.reservation_id != null) {
        const reservation = await tx.sklad_reservations.findUnique({
          where: { id: item.reservation_id },
        });
        if (reservation) {
          const restoredRemaining = Number(reservation.remaining_qty) + itemQty;
          // Restore to ACTIVE if it was FULFILLED
          const newStatus =
            reservation.status === "FULFILLED" ? "ACTIVE" : reservation.status;
          await tx.sklad_reservations.update({
            where: { id: item.reservation_id },
            data: {
              remaining_qty: restoredRemaining,
              status: newStatus,
              modified_at: new Date(),
            },
          });
        }
      }
    }

    await tx.sklad_issues.update({
      where: { id: issueId },
      data: { status: "CANCELLED", modified_at: new Date() },
    });

    return { data: { id: issueId } };
  });
}

// ---------------------------------------------------------------------------
// Issue Reservation Validation
// ---------------------------------------------------------------------------

/**
 * Validates that issue lines respect reservation rules:
 * - Lines WITH reservation_id: reservation must be ACTIVE, match the line's
 *   item_id and the issue's project_id, and have remaining_qty >= quantity.
 * - Lines WITHOUT reservation_id: the total unreserved quantity for that item
 *   must cover all non-reserved lines. Available = totalStock - activeReservations.
 * - Multiple lines can reference the same reservation; their combined quantity
 *   must not exceed the reservation's remaining_qty.
 */
export async function validateIssueReservationRules(
  lines: Array<{
    item_id: number;
    quantity: number;
    reservation_id: number | null;
  }>,
  issueProjectId: number,
  tx?: TxClient,
): Promise<{ valid: true } | { error: string; status: number }> {
  const client = tx ?? prisma;

  // Group lines by item_id
  const linesByItem = new Map<
    number,
    {
      unreservedQty: number;
      reservationLines: Array<{ reservation_id: number; quantity: number }>;
    }
  >();

  for (const line of lines) {
    const existing = linesByItem.get(line.item_id) ?? {
      unreservedQty: 0,
      reservationLines: [],
    };
    if (line.reservation_id) {
      existing.reservationLines.push({
        reservation_id: line.reservation_id,
        quantity: line.quantity,
      });
    } else {
      existing.unreservedQty += line.quantity;
    }
    linesByItem.set(line.item_id, existing);
  }

  for (const [itemId, group] of linesByItem) {
    // Validate reservation lines: aggregate by reservation_id
    const reservationQtyMap = new Map<number, number>();
    for (const rl of group.reservationLines) {
      const existing = reservationQtyMap.get(rl.reservation_id) ?? 0;
      reservationQtyMap.set(rl.reservation_id, existing + rl.quantity);
    }

    for (const [reservationId, totalQty] of reservationQtyMap) {
      const reservation = await client.sklad_reservations.findUnique({
        where: { id: reservationId },
      });
      if (!reservation) {
        return {
          error: `Rezervace ID ${reservationId} nebyla nalezena`,
          status: 404,
        };
      }
      if (reservation.status !== "ACTIVE") {
        return {
          error: `Rezervace ID ${reservationId} není aktivní (stav: ${reservation.status})`,
          status: 400,
        };
      }
      if (reservation.item_id !== itemId) {
        return {
          error: `Rezervace ID ${reservationId} nepatří k této položce`,
          status: 400,
        };
      }
      if (reservation.project_id !== issueProjectId) {
        return {
          error: `Rezervace ID ${reservationId} patří k jinému projektu`,
          status: 400,
        };
      }
      if (Number(reservation.remaining_qty) < totalQty) {
        return {
          error: `Rezervace ID ${reservationId} nemá dostatečné zbývající množství (${reservation.remaining_qty} < ${totalQty})`,
          status: 400,
        };
      }
    }

    // Validate unreserved lines
    if (group.unreservedQty > 0) {
      const totalStock = await getItemTotalStock(itemId);
      const reservedResult = await client.sklad_reservations.aggregate({
        _sum: { remaining_qty: true },
        where: { item_id: itemId, status: "ACTIVE" },
      });
      const reservedQty = Number(reservedResult._sum.remaining_qty ?? 0);
      const availableQty = totalStock - reservedQty;

      if (availableQty < group.unreservedQty) {
        return {
          error: `Nedostatečné dostupné množství pro položku ID ${itemId} (dostupné: ${availableQty}, požadované: ${group.unreservedQty}). Položka má aktivní rezervace — připojte výdej k rezervaci.`,
          status: 400,
        };
      }
    }
  }

  return { valid: true };
}

// ---------------------------------------------------------------------------
// Reservation Create
// ---------------------------------------------------------------------------

/**
 * Create a reservation:
 * Validate item exists and is_active.
 * Calculate available_qty (total stock - active reservations).
 * Validate available_qty >= quantity.
 * Create reservation with quantity = remaining_qty.
 */
export async function createReservation(data: {
  item_id: number;
  project_id: number;
  quantity: number;
  reserved_by?: number;
  notes?: string | null;
}): Promise<
  { data: Record<string, unknown> } | { error: string; status: number }
> {
  return prisma.$transaction(async (tx) => {
    // Lock the item row first. Without this, two concurrent reservations
    // could both read `available_qty = 100`, both decide `100 >= 50` and
    // `100 >= 60`, and both create reservations — oversubscribing the stock
    // because the second reservation's existence wasn't visible to the first.
    await lockParentRow(tx, "sklad_items", data.item_id);

    const item = await tx.sklad_items.findUnique({
      where: { id: data.item_id },
    });

    if (!item) {
      return { error: "Položka nebyla nalezena", status: 404 };
    }

    if (!item.is_active) {
      return { error: "Položka není aktivní", status: 400 };
    }

    // Re-read available qty inside the lock so the second concurrent caller
    // sees the first caller's reservation (created below) already counted.
    const availableQty = await getItemAvailableQtyTx(data.item_id, tx);

    if (availableQty < data.quantity) {
      return {
        error: `Nedostatečné dostupné množství (dostupné: ${availableQty}, požadované: ${data.quantity})`,
        status: 400,
      };
    }

    const reservation = await tx.sklad_reservations.create({
      data: {
        item_id: data.item_id,
        project_id: data.project_id,
        quantity: data.quantity,
        remaining_qty: data.quantity,
        reserved_by: data.reserved_by ?? null,
        notes: data.notes ?? null,
        status: "ACTIVE",
      },
    });

    return { data: reservation };
  });
}

// ---------------------------------------------------------------------------
// Reservation Cancel
// ---------------------------------------------------------------------------

/**
 * Set reservation status to CANCELLED, remaining_qty to 0.
 */
export async function cancelReservation(
  reservationId: number,
): Promise<{ data: { id: number } } | { error: string; status: number }> {
  return prisma.$transaction(async (tx) => {
    // Lock the reservation row FOR UPDATE first, exactly like the other
    // confirm/cancel ops lock their parent row. confirmIssue() fulfilling the
    // SAME reservation also updates this row (decrements remaining_qty / flips
    // it to FULFILLED) — without this lock the two interleave: a concurrent
    // confirm could fulfill the reservation between our read and our write,
    // and we'd then overwrite a FULFILLED reservation back to CANCELLED with
    // remaining_qty:0, losing the fulfillment and double-counting stock.
    // The status check below is now authoritative.
    await tx.$queryRaw`SELECT id FROM sklad_reservations WHERE id = ${reservationId} FOR UPDATE`;

    const reservation = await tx.sklad_reservations.findUnique({
      where: { id: reservationId },
    });

    if (!reservation) {
      return { error: "Rezervace nebyla nalezena", status: 404 };
    }

    if (reservation.status === "CANCELLED") {
      return { error: "Rezervace je již zrušena", status: 400 };
    }

    if (reservation.status === "FULFILLED") {
      return { error: "Rezervace je již vyplněna", status: 400 };
    }

    await tx.sklad_reservations.update({
      where: { id: reservationId },
      data: {
        status: "CANCELLED",
        remaining_qty: 0,
        modified_at: new Date(),
      },
    });

    return { data: { id: reservationId } };
  });
}

// ---------------------------------------------------------------------------
// Inventory Confirm
// ---------------------------------------------------------------------------

/**
 * Confirms an inventory session:
 * 1. Validate status is DRAFT
 * 2. For each line where difference != 0:
 *    - If diff > 0: create corrective receipt (auto-confirmed), create batch,
 *      update item_locations
 *    - If diff < 0: consume from oldest batches (FIFO), update item_locations
 * 3. Generate session_number via number_sequences
 * 4. Update status to CONFIRMED
 */
export async function confirmInventorySession(
  sessionId: number,
): Promise<
  | { data: { id: number; session_number: string } }
  | { error: string; status: number }
> {
  return prisma.$transaction(async (tx) => {
    // Lock the inventory session row so two concurrent confirms on the same
    // session cannot both create corrective receipts and double-decrement.
    await lockParentRow(tx, "sklad_inventory_sessions", sessionId);

    const session = await tx.sklad_inventory_sessions.findUnique({
      where: { id: sessionId },
      include: { items: true },
    });

    if (!session) {
      return { error: "Inventarizace nebyla nalezena", status: 404 };
    }

    if (session.status !== "DRAFT") {
      return { error: "Inventarizace není ve stavu DRAFT", status: 400 };
    }

    // Lock every affected item row FOR UPDATE (ascending id order) before the
    // read-modify-write on batch quantities / item_locations below. This
    // serializes a concurrent confirm/issue touching the same items so they
    // can't both create corrective receipts or both FIFO-consume the same
    // batches. Affected batches are additionally locked per-item once FIFO
    // has selected them (still in ascending id order — see lockRowsForUpdate).
    await lockRowsForUpdate(
      tx,
      "sklad_items",
      session.items.map((item) => item.item_id),
    );

    for (const item of session.items) {
      const diff = Number(item.difference);
      if (diff === 0) continue;

      if (diff > 0) {
        // Positive difference: stock surplus -> create corrective receipt.
        // NOTE (known valuation choice, not redesigned here): the surplus batch
        // enters at unit_price:0, so a found-surplus carries no stock value.
        // This is intentional for now (we don't know the real cost of stock
        // that "appeared"); revisit if inventory valuation needs the surplus
        // priced at e.g. the item's last/average cost.
        const receiptNumber = await generateWarehouseNumber(
          "warehouse_receipt",
          tx,
        );

        const correctiveReceipt = await tx.sklad_receipts.create({
          data: {
            receipt_number: receiptNumber,
            notes: `Korekční příjem z inventarizace #${sessionId}`,
            status: "CONFIRMED",
          },
        });

        const receiptLine = await tx.sklad_receipt_lines.create({
          data: {
            receipt_id: correctiveReceipt.id,
            item_id: item.item_id,
            quantity: diff,
            unit_price: 0,
            location_id: item.location_id,
            notes: `Korekce inventarizace (přebytek)`,
          },
        });

        // Create batch for the corrective receipt
        await tx.sklad_batches.create({
          data: {
            item_id: item.item_id,
            receipt_line_id: receiptLine.id,
            quantity: diff,
            original_qty: diff,
            unit_price: 0,
            received_at: new Date(),
            is_consumed: false,
          },
        });

        // Upsert item_locations
        if (item.location_id != null) {
          await tx.sklad_item_locations.upsert({
            where: {
              item_id_location_id: {
                item_id: item.item_id,
                location_id: item.location_id,
              },
            },
            create: {
              item_id: item.item_id,
              location_id: item.location_id,
              quantity: diff,
            },
            update: {
              quantity: {
                increment: diff,
              },
            },
          });
        }
      } else {
        // Negative difference: stock deficit -> consume from oldest batches (FIFO)
        const absDiff = Math.abs(diff);

        try {
          const fifoBatches = await selectFifoBatches(
            item.item_id,
            absDiff,
            tx,
          );

          // Lock the selected batch rows FOR UPDATE (ascending id order) before
          // decrementing them, so a concurrent issue/inventory consuming the
          // same batches serializes behind us and can't lose an update.
          await lockRowsForUpdate(
            tx,
            "sklad_batches",
            fifoBatches.map((fb) => fb.batchId),
          );

          for (const fb of fifoBatches) {
            const batch = await tx.sklad_batches.findUnique({
              where: { id: fb.batchId },
            });
            if (!batch) continue;

            const newBatchQty = Number(batch.quantity) - fb.qty;
            await tx.sklad_batches.update({
              where: { id: fb.batchId },
              data: {
                quantity: newBatchQty,
                is_consumed: newBatchQty <= 0,
              },
            });
          }

          // Decrement item_locations.
          // NOTE (known valuation drift, not redesigned here): for a negative
          // difference we consume FIFO batches (which may live at any location)
          // but decrement item_locations only for THIS line's location_id by
          // the full absDiff. If the consumed batches and the counted location
          // diverge, location quantities can drift from batch totals. This is a
          // pre-existing valuation/location-tracking limitation; correcting it
          // would require per-batch location tracking and is out of scope.
          if (item.location_id != null) {
            const loc = await tx.sklad_item_locations.findUnique({
              where: {
                item_id_location_id: {
                  item_id: item.item_id,
                  location_id: item.location_id,
                },
              },
            });

            if (loc) {
              const newLocQty = Number(loc.quantity) - absDiff;
              if (newLocQty <= 0) {
                await tx.sklad_item_locations.delete({
                  where: { id: loc.id },
                });
              } else {
                await tx.sklad_item_locations.update({
                  where: { id: loc.id },
                  data: { quantity: newLocQty },
                });
              }
            }
          }
        } catch (err) {
          const message =
            err instanceof Error
              ? err.message
              : "Nedostatečné množství na skladě";
          return {
            error: `Inventarizace položky ID ${item.item_id}: ${message}`,
            status: 400,
          };
        }
      }
    }

    const sessionNumber = await generateWarehouseNumber(
      "warehouse_inventory",
      tx,
    );

    await tx.sklad_inventory_sessions.update({
      where: { id: sessionId },
      data: {
        status: "CONFIRMED",
        session_number: sessionNumber,
        modified_at: new Date(),
      },
    });

    return { data: { id: sessionId, session_number: sessionNumber } };
  });
}
