// Inventory adjustments and opening quantities — Task 3 of
// docs/superpowers/plans/2026-08-03-inventory.md (spec:
// docs/superpowers/specs/2026-08-03-inventory-design.md). Same route-module
// pattern as items.ts: everything under /api/c/ (company scope from
// X-Company-Id via companyContextPlugin), every query inside db.withCompany()
// so RLS is the real guard (CLAUDE.md §4/§12).
//
//   POST /api/c/inventory-adjustments     shrinkage, breakage, a count correction
//   POST /api/c/inventory-opening         what was already on the shelf on day one
//
// Both are thin: record_adjustment() (db/migrations/0018_inventory.sql) and
// record_opening_quantities() (db/migrations/0023_inventory_selling_builds.sql)
// own BOTH halves of the fact — the stock movement and the journal entry, in
// one transaction — which is exactly why they live in the database and not
// here. Every refusal below is the database's; this module turns it into a
// status code.
//
// ---------------------------------------------------------------------------
// WHY OPENING QUANTITIES ARE NOT JUST A PILE OF ADJUSTMENTS.
//
// record_adjustment() refuses an ASSET on the other side, and it is right to:
// shrinkage posted to Cash silently misstates the bank balance and corrupts the
// next reconciliation. But CLAUDE.md §7 imports FULL transaction history, so a
// migrated company's old "1300 Inventory" already carries a real balance before
// any item exists — and the cure the spec prescribes is a reclassification:
//
//     Dr <new inventory account>     Cr <the old history-funded account>
//
// which credits an asset. Done as two steps (adjust against equity, then drain
// the old account), it works and ties throughout — but BETWEEN THE TWO STEPS
// TOTAL ASSETS ARE OVERSTATED, because the new account already holds the value
// while the old one has not yet been emptied. One entry has no such window,
// which is why this endpoint exists and why it names its source account.
//
// A fresh company names Opening Balance Equity instead, and gets the same one
// entry. A short count is legitimate and expected: if the shelf adds up to less
// than history said, the difference stays in the source account, which for a
// migrated company means the shortfall lands where a prior-period error belongs
// rather than hiding in an asset.
// ---------------------------------------------------------------------------

import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { Db } from "./db.js";
import { mapPgError } from "./errors.js";
import { findOwnerId } from "../../tools/lib/journal-load.js";
import { blankToUndefined } from "./lib/textNormalize.js";

// ---------------------------------------------------------------------------
// validation
// ---------------------------------------------------------------------------

const idSchema = z.string().regex(/^\d{1,18}$/, "That isn't something we recognise — go back and pick it again.");

const isoDateSchema = z.iso.date({ error: "must be a date like 2024-01-15 (YYYY-MM-DD)" });

// A memo is REQUIRED on an adjustment and on an opening count. The database
// says so too (record_adjustment / record_opening_quantities), and it is the
// same reason in both places: a quantity that changed for no recorded reason is
// impossible to audit later.
const requiredMemoSchema = z.preprocess(
  blankToUndefined,
  z.string({ error: "Say why the quantity is changing." })
    .max(1000, "That note is too long."),
);

// SIGNED quantity: "+12" found, "-3" gone. numeric(19,4) in storage; the
// 8-digit integer cap matches every other quantity in this app so an absurd
// value is a friendly 400 rather than a raw Postgres overflow. A regex, never
// Number()/parseFloat() (CLAUDE.md §2).
const signedQuantitySchema = z
  .string({ error: "Enter how much the quantity is changing by." })
  .regex(
    /^-?(0|[1-9]\d{0,7})(\.\d{1,4})?$/,
    "Enter a quantity like 12 or -3 (up to 4 decimal places, no leading zeros, no more than 8 digits before the decimal point).",
  )
  .refine((v) => /[1-9]/.test(v), { message: "Enter a quantity that isn't zero." });

const positiveQuantitySchema = z
  .string({ error: "Enter how many you have on hand." })
  .regex(
    /^(0|[1-9]\d{0,7})(\.\d{1,4})?$/,
    "Enter a quantity like 12 or 0.5 (up to 4 decimal places, no leading zeros, no more than 8 digits before the decimal point).",
  )
  .refine((v) => /[1-9]/.test(v), { message: "Enter a quantity greater than zero." });

// Up to FOUR decimal places, matching items.purchase_cost — sub-cent costs are
// real (a washer costs $0.0042). Zero is allowed; stock can genuinely arrive
// free.
const unitCostSchema = z
  .string()
  .regex(
    /^(0|[1-9]\d{0,12})(\.\d{1,4})?$/,
    "Enter an amount like 12 or 12.50 (up to 4 decimal places, no leading zeros, no more than 13 digits before the decimal point).",
  );

const adjustmentSchema = z.object({
  itemId: idSchema,
  date: isoDateSchema,
  quantity: signedQuantitySchema,
  unitCost: z.preprocess(blankToUndefined, unitCostSchema.optional()),
  accountId: idSchema,
  memo: requiredMemoSchema,
});

const openingSchema = z.object({
  date: isoDateSchema,
  sourceAccountId: idSchema,
  memo: requiredMemoSchema,
  // "I have read the double-count warning and mean to do this anyway."
  // record_opening_quantities() (0025) refuses an EQUITY source on a company
  // whose ledger carries imported entries unless this is true, because CLAUDE.md
  // §7's "history either replaces it or does not happen" applies to opening
  // QUANTITIES exactly as it applies to opening balances. Defaults to false:
  // a caller that says nothing has not been warned.
  acknowledged: z.boolean().optional(),
  items: z.array(z.object({
    itemId: idSchema,
    quantity: positiveQuantitySchema,
    unitCost: unitCostSchema,
  })).min(1, "List at least one item and how many of it you have on hand."),
});

function zodMessage(issues: Array<{ message: string }>): string {
  return issues.map((i) => i.message).join("; ");
}

const NO_OWNER_ERROR = {
  status: 500,
  error: "This company doesn't have an owner user on file, so this can't be attributed. This shouldn't happen.",
};

async function resolveOwner(
  db: Db, companyId: string, log: { error: (obj: unknown, msg: string) => void },
): Promise<string | null> {
  try {
    return await findOwnerId(db.adminPool, companyId);
  } catch (err) {
    log.error({ err, companyId }, "resolveOwner: findOwnerId failed");
    return null;
  }
}

/**
 * "5.0000" -> "5", "0.5000" -> "0.5". A quantity column's trailing zeros are a
 * storage detail, and a sentence a user reads must not carry them ("Took 5.0000
 * Brass Hinge off the shelf ... 35.0000 left" — caught in Task 5's live
 * verification). Mirrors server/src/purchaseOrders.ts's helper of the same name
 * and web/src/items/itemTypes.ts's fmtQuantity. A quantity is not money, so
 * plain string trimming is right here (CLAUDE.md §2 is about monetary values)
 * — and it is still string surgery, never a parsed number.
 */
function formatQuantity(raw: string): string {
  if (!/^-?\d+\.\d+$/.test(raw)) return raw;
  return raw.replace(/0+$/, "").replace(/\.$/, "");
}

/** Formats a NUMERIC(19,4) string as "$1,234.00" — pure string surgery, never a
 *  parsed JS number (CLAUDE.md §2). */
function formatUsd(amount: string): string {
  const negative = amount.startsWith("-");
  const unsigned = negative ? amount.slice(1) : amount;
  const [wholeRaw, fracRaw = ""] = unsigned.split(".");
  const whole = wholeRaw!.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  const frac = (fracRaw + "00").slice(0, 2);
  return `${negative ? "-" : ""}$${whole}.${frac}`;
}

// ---------------------------------------------------------------------------
// route registration
// ---------------------------------------------------------------------------

export function registerInventoryAdjustmentRoutes(fastify: FastifyInstance, db: Db): void {
  // -------------------------------------------------------------------------
  // POST /api/c/inventory-adjustments
  // -------------------------------------------------------------------------
  fastify.post("/api/c/inventory-adjustments", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = adjustmentSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const body = parsed.data;

    // Stock going OUT is worth what it is already worth — its running average —
    // and letting a caller name a cost would be a second source of truth.
    // record_movement() refuses it too, in those words; this says it earlier and
    // in the vocabulary of the form the user is looking at.
    const removing = body.quantity.startsWith("-");
    if (removing && body.unitCost !== undefined) {
      return reply.code(422).send({
        error: "Stock going out is valued at what it already cost you on average, so leave the cost blank.",
      });
    }
    if (!removing && body.unitCost === undefined) {
      return reply.code(422).send({
        error: "Enter what this stock cost you — stock can't come in without a cost.",
      });
    }

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) {
      return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });
    }

    try {
      const result = await db.withCompany(companyId, async (client) => {
        // RLS makes another company's item id look exactly like an unknown one,
        // so look it up here for a clean 404.
        const itemRes = await client.query<{ id: string; name: string }>(
          "SELECT id, name FROM items WHERE company_id = $1 AND id = $2",
          [companyId, body.itemId],
        );
        const item = itemRes.rows[0];
        if (!item) return { found: false as const };

        const res = await client.query<{ entry_id: string }>(
          "SELECT record_adjustment($1, $2, $3::date, $4::numeric, $5::numeric, $6, $7, $8) AS entry_id",
          [companyId, body.itemId, body.date, body.quantity, body.unitCost ?? null,
            body.accountId, body.memo, ownerId],
        );
        const entryId = res.rows[0]!.entry_id;

        // Read back what was actually STORED rather than echoing what was
        // asked for: record_movement() rounds the quantity to the column's own
        // scale, and an issue's value comes from the running average.
        const movementRes = await client.query<{
          qty: string; value: string; unit_cost: string; qty_on_hand: string;
        }>(
          `SELECT m.qty, m.value, m.unit_cost,
                  item_qty_on_hand($1, m.item_id) AS qty_on_hand
             FROM inventory_movements m
            WHERE m.company_id = $1 AND m.entry_id = $2
            ORDER BY m.id LIMIT 1`,
          [companyId, entryId],
        );
        return { found: true as const, item, entryId, movement: movementRes.rows[0]! };
      });

      if (!result.found) return reply.code(404).send({ error: "We couldn't find that item." });

      const m = result.movement;
      const wentOut = m.qty.startsWith("-");
      const magnitude = formatQuantity(wentOut ? m.qty.slice(1) : m.qty);
      return reply.code(201).send({
        entryId: result.entryId,
        itemId: result.item.id,
        qty: m.qty,
        value: m.value,
        unitCost: m.unit_cost,
        qtyOnHand: m.qty_on_hand,
        summary: wentOut
          ? `Took ${magnitude} ${result.item.name} off the shelf — ${formatUsd(m.value.replace(/^-/, ""))} of value. ${formatQuantity(m.qty_on_hand)} left.`
          : `Added ${magnitude} ${result.item.name} — ${formatUsd(m.value)} of value. ${formatQuantity(m.qty_on_hand)} on hand.`,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "inventory adjustment failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/inventory-opening
  // -------------------------------------------------------------------------
  fastify.post("/api/c/inventory-opening", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = openingSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const body = parsed.data;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) {
      return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });
    }

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const accountRes = await client.query<{ id: string; name: string; type: string }>(
          "SELECT id, name, type FROM accounts WHERE company_id = $1 AND id = $2",
          [companyId, body.sourceAccountId],
        );
        const account = accountRes.rows[0];
        if (!account) return { found: false as const };

        // Money and quantities as JSON STRINGS all the way into the database —
        // jsonb numbers survive Postgres exactly, but every JSON writer between
        // here and there turns them into floats first (CLAUDE.md §2).
        const payload = JSON.stringify(body.items.map((i) => ({
          item_id: i.itemId, qty: i.quantity, unit_cost: i.unitCost,
        })));

        const res = await client.query<{ entry_id: string }>(
          "SELECT record_opening_quantities($1, $2::date, $3, $4::jsonb, $5, $6, $7) AS entry_id",
          [companyId, body.date, body.sourceAccountId, payload, body.memo, ownerId,
            body.acknowledged ?? false],
        );
        const entryId = res.rows[0]!.entry_id;

        const totalRes = await client.query<{ total: string; items: number }>(
          `SELECT coalesce(sum(value), 0)::numeric(19,4) AS total, count(*)::int AS items
             FROM inventory_movements WHERE company_id = $1 AND entry_id = $2`,
          [companyId, entryId],
        );
        return { found: true as const, account, entryId, totals: totalRes.rows[0]! };
      });

      if (!result.found) {
        return reply.code(422).send({ error: "Pick the account this opening value comes from." });
      }
      return reply.code(201).send({
        entryId: result.entryId,
        sourceAccountId: result.account.id,
        sourceAccountName: result.account.name,
        itemCount: result.totals.items,
        totalValue: result.totals.total,
        summary: `Recorded ${result.totals.items} opening ${result.totals.items === 1 ? "quantity" : "quantities"} worth ${formatUsd(result.totals.total)}, taken out of ${result.account.name}.`,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "opening quantities failed");
      // 0025's double-count refusal is the one here that a user can legitimately
      // override, so it is flagged STRUCTURALLY rather than leaving the screen
      // to match on English prose (which would break the moment the sentence is
      // reworded, and silently). The message still comes from the database —
      // this only says "that particular refusal, the one with an answer".
      if (isDoubleCountRefusal(err)) {
        return reply.code(mapped.status).send({
          error: mapped.message,
          needsAcknowledgement: true,
        });
      }
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });
}

/** True for record_opening_quantities()'s imported-history refusal (0025) —
 *  the only refusal on this route that a caller may answer by resubmitting with
 *  `acknowledged: true`. Matched on the RAISE's own opening clause, which the
 *  migration and tests both pin. */
function isDoubleCountRefusal(err: unknown): boolean {
  const message = (err as { message?: string } | null)?.message ?? "";
  return /^This company brought in transaction history/.test(message);
}
