// Verb-first manual entries API — Task 5 of
// docs/superpowers/plans/2026-07-31-local-app.md. Two routes, all under
// /api/c/ (company scope from X-Company-Id, enforced by companyContextPlugin):
//
//   POST /api/c/entries                   — record money in / out / a transfer
//   POST /api/c/entries/:id/reverse       — undo a posted entry
//
// (GET /api/c/accounts, originally this module's third route, moved to
// accounts.ts in the chart-management milestone.)
//
// CLAUDE.md §9 is binding: nothing here ever says "debit" or "credit" in a
// message a user sees. The UI has one verb-first flow per kind of thing that
// happened ("money came in", "money went out", "moved between accounts") —
// this module is what decides, server-side, that e.g. "money came in" means
// "debit the money account, credit the category" and builds that balanced
// 2-line entry. Account-kind validation (asset vs. not) happens here, against
// the SAME RLS-scoped transaction that does the insert, so a stale picker on
// the client can never post into another company's account or the wrong
// kind of account (§4/§12 — the database, not this file, is the real guard;
// this file only adds friendlier English on top of what the DB would reject
// anyway via the composite FKs and post_entry()).
//
// created_by is always the company's OWNER user (see resolveOwner below) —
// same pattern imports.ts uses for import batches: there's no login in v1
// (design spec: "single operator; user attribution still recorded via the
// owner user"), so every manual entry is attributed to whoever owns the
// company, not to a Postgres role (CLAUDE.md §4: "do not use current_user /
// the Postgres role for attribution").
//
// Amount handling: amounts are validated with a regex (never parsed to a
// float) and formatted for the summary sentence by splitting on '.' and
// inserting thousands separators as plain string surgery — see formatUsd().
// Storage stays debit-positive everywhere (§12); this module decides which
// side of the balanced pair is the debit and which is the credit, but never
// flips a sign in presentation the way reports.ts's flipSign() does.

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";

// ---------------------------------------------------------------------------
// shared validation + helpers
// ---------------------------------------------------------------------------

// Same bound as reports.ts's idParamSchema: every id here is a Postgres
// bigint (GENERATED ALWAYS AS IDENTITY, max 19 digits) — capping at 18 digits
// turns a too-long id into a friendly 400 instead of a raw Postgres
// "bigint out of range" 500.
const idSchema = z.string().regex(/^\d{1,18}$/, "must be a whole number id (no more than 18 digits)");

const idParamSchema = z.object({ id: idSchema });

const isoDateSchema = z.iso.date({ error: "must be a date like 2024-01-15 (YYYY-MM-DD)" });
const optionalIsoDate = z.preprocess(
  (v) => (v === "" || v === undefined ? undefined : v),
  isoDateSchema.optional(),
);

// Deliberately a regex, never Number()/parseFloat() on the amount itself
// (CLAUDE.md §2 — money is never a raw JS number, not even transiently):
// the whole-dollar part is either a bare "0" or a non-zero digit followed by
// more digits (no leading zeros like "007" or "01.50" — those aren't how
// anyone types an amount and silently accepting them invites transcription
// errors), at most 2 decimal places, no sign, no exponent notation. The
// positivity check below (`/[1-9]/`) is string inspection too — "is there a
// nonzero digit anywhere" — not a numeric comparison.
const amountSchema = z
  .string()
  .regex(
    /^(0|[1-9]\d*)(\.\d{1,2})?$/,
    "Enter an amount like 120 or 120.50 (no more than 2 decimal places, no leading zeros).",
  )
  .refine((v) => /[1-9]/.test(v), { message: "Enter an amount greater than zero." });

const whoSchema = z.string().trim().min(1).max(200).optional();
const memoSchema = z.string().trim().min(1).max(1000).optional();

const moneyInSchema = z.object({
  kind: z.literal("money-in"),
  date: isoDateSchema,
  amount: amountSchema,
  intoAccountId: idSchema,
  categoryAccountId: idSchema,
  who: whoSchema,
  memo: memoSchema,
});
const moneyOutSchema = z.object({
  kind: z.literal("money-out"),
  date: isoDateSchema,
  amount: amountSchema,
  fromAccountId: idSchema,
  categoryAccountId: idSchema,
  who: whoSchema,
  memo: memoSchema,
});
const transferSchema = z.object({
  kind: z.literal("transfer"),
  date: isoDateSchema,
  amount: amountSchema,
  fromAccountId: idSchema,
  toAccountId: idSchema,
  memo: memoSchema,
});

// The per-kind object schemas stay plain (no .refine()) so they can be
// members of a discriminated union; the transfer-specific "different
// accounts" rule is applied to the union's OUTPUT as a second pass below.
const entryBodySchema = z
  .discriminatedUnion("kind", [moneyInSchema, moneyOutSchema, transferSchema])
  .refine((v) => v.kind !== "transfer" || v.fromAccountId !== v.toAccountId, {
    message: "Pick two different accounts to move money between.",
    path: ["toAccountId"],
  });

type EntryBody = z.infer<typeof entryBodySchema>;

const reverseBodySchema = z.object({ date: optionalIsoDate });

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

const NOT_FOUND_ENTRY = { error: "We couldn't find that entry." };

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

/**
 * Resolves the company's owner user id (see findOwnerId — every manual
 * entry is attributed to the owner, not a login, per the design spec: no
 * auth UI in v1). Mirrors imports.ts's resolveOwnerOrNull: findOwnerId()
 * throws both for "genuinely no owner" and for any underlying DB fault, so
 * the real error is logged here and the caller gets a clean null either way.
 */
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;
  }
}

/**
 * Formats a validated amount string (see amountSchema — already known to
 * match /^\d+(\.\d{1,2})?$/) as "$1,234.50": split on '.', pad the fraction
 * to exactly 2 digits, comma-group the integer part. No float math anywhere,
 * per CLAUDE.md §2 — this is pure string surgery on digits already proven
 * well-formed by the schema above.
 */
function formatUsd(amount: string): string {
  const [wholeRaw, fracRaw = ""] = amount.split(".");
  const whole = wholeRaw!.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  const frac = (fracRaw + "00").slice(0, 2);
  return `$${whole}.${frac}`;
}

interface AccountRow {
  id: string;
  type: string;
  name: string;
}

/** RLS-scoped lookup of one active account by id, within the caller's transaction. */
async function lookupAccount(
  client: { query: <T>(text: string, params?: unknown[]) => Promise<{ rows: T[] }> },
  companyId: string, accountId: string,
): Promise<AccountRow | null> {
  const res = await client.query<AccountRow>(
    "SELECT id, type, name FROM accounts WHERE company_id = $1 AND id = $2 AND active = true",
    [companyId, accountId],
  );
  return res.rows[0] ?? null;
}

interface ResolvedLines {
  // debit account/credit account, in that order — matches the plan's
  // "money-in: debit into, credit category" etc. exactly.
  debitAccount: AccountRow;
  creditAccount: AccountRow;
  summary: string;
}

const MONEY_ACCOUNT_MESSAGES = {
  into: "Pick a money account for where it landed.",
  from: "Pick a money account for where it came from.",
  transferFrom: "Pick a money account to move money out of.",
  transferTo: "Pick a money account to move money into.",
};
const CATEGORY_MESSAGES = {
  moneyIn: "Pick a category for where this came from — not a money account.",
  moneyOut: "Pick a category for what this was spent on — not a money account.",
};

/**
 * Validates account kinds for one entry body and, if valid, returns the
 * (debit, credit) account pair plus the plain-English summary sentence.
 * Returns a {status, error} shape (never throws) so the route handler can
 * respond 422 without a wasted INSERT — CLAUDE.md §9 "Errors in English".
 */
async function resolveEntry(
  client: { query: <T>(text: string, params?: unknown[]) => Promise<{ rows: T[] }> },
  companyId: string, body: EntryBody,
): Promise<ResolvedLines | { status: number; error: string }> {
  if (body.kind === "money-in") {
    const into = await lookupAccount(client, companyId, body.intoAccountId);
    if (!into || into.type !== "asset") return { status: 422, error: MONEY_ACCOUNT_MESSAGES.into };
    const category = await lookupAccount(client, companyId, body.categoryAccountId);
    if (!category || category.type === "asset") return { status: 422, error: CATEGORY_MESSAGES.moneyIn };
    return {
      debitAccount: into,
      creditAccount: category,
      summary: `Recorded ${formatUsd(body.amount)} in from ${category.name} into ${into.name}.`,
    };
  }

  if (body.kind === "money-out") {
    const from = await lookupAccount(client, companyId, body.fromAccountId);
    if (!from || from.type !== "asset") return { status: 422, error: MONEY_ACCOUNT_MESSAGES.from };
    const category = await lookupAccount(client, companyId, body.categoryAccountId);
    if (!category || category.type === "asset") return { status: 422, error: CATEGORY_MESSAGES.moneyOut };
    return {
      debitAccount: category,
      creditAccount: from,
      summary: `Recorded ${formatUsd(body.amount)} spent on ${category.name} from ${from.name}.`,
    };
  }

  // transfer
  const from = await lookupAccount(client, companyId, body.fromAccountId);
  if (!from || from.type !== "asset") return { status: 422, error: MONEY_ACCOUNT_MESSAGES.transferFrom };
  const to = await lookupAccount(client, companyId, body.toAccountId);
  if (!to || to.type !== "asset") return { status: 422, error: MONEY_ACCOUNT_MESSAGES.transferTo };
  return {
    debitAccount: to,
    creditAccount: from,
    summary: `Moved ${formatUsd(body.amount)} from ${from.name} to ${to.name}.`,
  };
}

/** Folds the optional "who" into the entry's memo — journal_entries has no
 * separate counterparty column (only `memo`), so "who" (a free-text name the
 * user typed, e.g. a customer or vendor) is prefixed onto whatever memo they
 * also typed, rather than silently discarded. */
function combineMemo(who: string | undefined, memo: string | undefined): string | null {
  const parts = [who, memo].filter((v): v is string => Boolean(v));
  return parts.length > 0 ? parts.join(" — ") : null;
}

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

export function registerEntryRoutes(fastify: FastifyInstance, db: Db): void {
  // GET /api/c/accounts moved to accounts.ts (chart-management milestone,
  // Task 1) — same route, same default shape, plus subtype/active fields and
  // the includeInactive/withBalances query params.

  // -------------------------------------------------------------------------
  // POST /api/c/entries
  // -------------------------------------------------------------------------
  fastify.post("/api/c/entries", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = entryBodySchema.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 {
      // IMPORTANT: never call reply.send() from inside db.withCompany()'s
      // callback. Fastify (via light-my-request's .inject(), and observably
      // in production too) considers the response complete the moment
      // reply.send() runs — it does NOT wait for the rest of this async
      // function (including withCompany's own `await client.query("COMMIT")`,
      // which runs AFTER the callback resolves) before treating the request
      // as done. Calling send() here raced the actual COMMIT: the HTTP
      // response could — and, once observed directly, reliably did —
      // complete with a real-looking entryId/entryNo BEFORE the transaction
      // that produced them had actually committed, so the write could still
      // be silently rolled back (or simply not yet visible) after the
      // client already believes it succeeded. Returning a plain discriminated
      // result and replying only once db.withCompany() has fully resolved
      // (below) guarantees COMMIT has already happened by the time anything
      // is sent.
      const result = await db.withCompany(companyId, async (client) => {
        const resolved = await resolveEntry(client, companyId, body);
        if ("error" in resolved) {
          return { ok: false as const, status: resolved.status, error: resolved.error };
        }

        // "who" only exists on money-in/money-out (a transfer has no
        // counterparty — it's between two of the company's own accounts).
        const who = body.kind === "transfer" ? undefined : body.who;
        const memo = combineMemo(who, body.memo);

        const entryRes = await client.query<{ id: string }>(
          `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
           VALUES ($1, $2, $3, $4) RETURNING id`,
          [companyId, body.date, memo, ownerId],
        );
        const entryId = entryRes.rows[0]!.id;

        await client.query(
          `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
           VALUES ($1, $2, $3, $4, 0), ($1, $2, $5, 0, $4)`,
          [companyId, entryId, resolved.debitAccount.id, body.amount, resolved.creditAccount.id],
        );

        const postRes = await client.query<{ entry_no: string }>(
          "SELECT post_entry($1, $2) AS entry_no", [companyId, entryId],
        );
        const entryNo = postRes.rows[0]!.entry_no;

        return { ok: true as const, entryId, entryNo, summary: resolved.summary };
      });

      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      return reply.code(201).send({
        entryId: result.entryId, entryNo: result.entryNo, summary: result.summary,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "record entry failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/entries/:id/reverse
  // -------------------------------------------------------------------------
  fastify.post("/api/c/entries/:id/reverse", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) {
      return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    }
    const parsedBody = reverseBodySchema.safeParse(request.body ?? {});
    if (!parsedBody.success) {
      return reply.code(400).send({ error: zodMessage(parsedBody.error.issues) });
    }
    const entryId = parsedParams.data.id;
    const reversalDate = parsedBody.data.date ?? null;

    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 {
      // See the identical note in POST /api/c/entries above: reply.send()
      // never happens inside this callback — only a plain discriminated
      // result, replied to below once db.withCompany() (and therefore its
      // COMMIT) has fully resolved.
      const result = await db.withCompany(companyId, async (client) => {
        // Look the original entry up ourselves first: RLS makes another
        // company's entry id look exactly like an unknown one (zero rows
        // either way), and this gives a clean 404 instead of letting
        // reverse_entry()'s own "entry % not found" surface as an unmapped
        // 500 (see errors.ts — that RAISE text has no English mapping,
        // deliberately, because this route is supposed to catch it first).
        const originalRes = await client.query<{ entry_no: string | null }>(
          "SELECT entry_no FROM journal_entries WHERE company_id = $1 AND id = $2",
          [companyId, entryId],
        );
        const original = originalRes.rows[0];
        if (!original) {
          return { found: false as const };
        }

        const reverseRes = await client.query<{ reversal_id: string }>(
          `SELECT reverse_entry($1, $2, coalesce($3::date, current_date), $4) AS reversal_id`,
          [companyId, entryId, reversalDate, ownerId],
        );
        const reversalEntryId = reverseRes.rows[0]!.reversal_id;

        const reversalRes = await client.query<{ entry_no: string }>(
          "SELECT entry_no FROM journal_entries WHERE company_id = $1 AND id = $2",
          [companyId, reversalEntryId],
        );
        const entryNo = reversalRes.rows[0]!.entry_no;

        return {
          found: true as const, reversalEntryId, entryNo, originalEntryNo: original.entry_no,
        };
      });

      if (!result.found) {
        return reply.code(404).send(NOT_FOUND_ENTRY);
      }
      return reply.code(201).send({
        reversalEntryId: result.reversalEntryId,
        entryNo: result.entryNo,
        summary: `Reversed entry #${result.originalEntryNo} — every amount put back exactly.`,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "reverse entry failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });
}
