// Integration tests for the verb-first manual entries API (Task 5 of
// docs/superpowers/plans/2026-07-31-local-app.md): grouped account pickers,
// money-in/out/transfer happy paths (checked both via the HTTP response and
// directly against journal_lines/trial_balance — same pattern as
// tests/server/reports.test.ts), amount/account-kind validation, locked
// periods, reversal (incl. double-reversal), and RLS.

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createTestDb, type TestDb } from "../helpers/db.js";
// Where the database is, resolved once for the whole run: your ADMIN_URL /
// DATABASE_URL if you set one, otherwise the PostgreSQL Balanced Sum installed on
// this machine. Every file in this directory used to carry its own copy of that
// decision, hardcoded to the Docker container that no longer exists.
import { dbUrl } from "../helpers/test-db-admin.js";
import { buildApp } from "../../server/src/app.js";
import type { FastifyInstance } from "fastify";

async function buildTestApp(dbName: string): Promise<FastifyInstance> {
  return buildApp({
    databaseUrl: await dbUrl(dbName, "app", "app"),
    adminDatabaseUrl: await dbUrl(dbName),
  });
}

function post(app: FastifyInstance, companyId: string, url: string, payload?: unknown) {
  return app.inject({
    method: "POST", url, headers: { "x-company-id": companyId }, payload: payload ?? {},
  });
}
function get(app: FastifyInstance, companyId: string, url: string) {
  return app.inject({ method: "GET", url, headers: { "x-company-id": companyId } });
}

let db: TestDb;
let app: FastifyInstance;

beforeAll(async () => {
  db = await createTestDb();
  app = await buildTestApp(db.dbName);
}, 60_000);

afterAll(async () => {
  await app.close();
  await db.close();
});

// tests/helpers/db.ts's seeded chart (both companies get an identical copy):
// 1000 Cash (asset), 1100 Accounts Receivable (asset), 1200 Due from Affiliate
// (asset), 2000 Accounts Payable (liability), 2200 Due to Affiliate
// (liability), 3000 Opening Equity (equity), 4000 Revenue (income),
// 6000 Operating Expense (expense).

describe("GET /api/c/accounts", () => {
  // Row shape gained `subtype` (null on the raw seeded test chart — migration
  // 0016's backfill only tags template/settings-designated accounts) and
  // `active` in the chart-management milestone; the route itself moved from
  // entries.ts to accounts.ts unchanged otherwise.
  it("returns a flat array of active accounts when ungrouped", async () => {
    const res = await get(app, db.companyA, "/api/c/accounts");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(Array.isArray(body)).toBe(true);
    expect(body).toHaveLength(8);
    expect(body[0]).toEqual({
      id: expect.any(String), code: expect.any(String), name: expect.any(String), type: expect.any(String),
      subtype: null, active: true,
    });
  });

  it("groups money/income/spending/other by account type", async () => {
    const res = await get(app, db.companyA, "/api/c/accounts?grouped=1");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.money.map((a: { code: string }) => a.code).sort()).toEqual(["1000", "1100", "1200"]);
    expect(body.income.map((a: { code: string }) => a.code)).toEqual(["4000"]);
    expect(body.spending.map((a: { code: string }) => a.code)).toEqual(["6000"]);
    expect(body.other.map((a: { code: string }) => a.code).sort()).toEqual(["2000", "2200", "3000"]);
    expect(body.money[0]).toEqual({
      id: expect.any(String), code: expect.any(String), name: expect.any(String), type: "asset",
      subtype: null, active: true,
    });
  });

  it("shows nothing for company B beyond its own identical chart (RLS)", async () => {
    const res = await get(app, db.companyB, "/api/c/accounts");
    const body = res.json();
    const ids = new Set(body.map((a: { id: string }) => a.id));
    for (const code of Object.keys(db.accounts)) {
      expect(ids.has(String(db.accounts[code]!.A))).toBe(false);
      expect(ids.has(String(db.accounts[code]!.B))).toBe(true);
    }
  });
});

describe("POST /api/c/entries — money-in", () => {
  it("records a balanced entry, folds who+memo, and returns the exact summary sentence", async () => {
    const res = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-in",
      date: "2024-03-01",
      amount: "500",
      intoAccountId: String(db.accounts["1000"]!.A),
      categoryAccountId: String(db.accounts["4000"]!.A),
      who: "Acme Co",
      memo: "March consulting",
    });
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(body.summary).toBe("Recorded $500.00 in from Revenue into Cash.");
    expect(typeof body.entryId).toBe("string");
    expect(typeof body.entryNo).toBe("string");

    const entryRow = await db.admin.query(
      "SELECT status, memo, created_by FROM journal_entries WHERE company_id = $1 AND id = $2",
      [db.companyA, body.entryId],
    );
    expect(entryRow.rows[0]).toMatchObject({ status: "posted", memo: "Acme Co — March consulting" });

    const lines = await db.admin.query(
      "SELECT account_id, debit, credit FROM journal_lines WHERE company_id = $1 AND entry_id = $2 ORDER BY id",
      [db.companyA, body.entryId],
    );
    expect(lines.rows).toEqual([
      { account_id: String(db.accounts["1000"]!.A), debit: "500.0000", credit: "0.0000" },
      { account_id: String(db.accounts["4000"]!.A), debit: "0.0000", credit: "500.0000" },
    ]);
  });

  it("allows a non-income category (e.g. an expense account, for a refund)", async () => {
    const res = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-in",
      date: "2024-03-02",
      amount: "50",
      intoAccountId: String(db.accounts["1000"]!.A),
      categoryAccountId: String(db.accounts["6000"]!.A),
    });
    expect(res.statusCode).toBe(201);
    expect(res.json().summary).toBe("Recorded $50.00 in from Operating Expense into Cash.");
  });

  it("422s with English when intoAccountId isn't a money (asset) account", async () => {
    const res = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-in",
      date: "2024-03-01",
      amount: "500",
      intoAccountId: String(db.accounts["4000"]!.A), // Revenue — not asset
      categoryAccountId: String(db.accounts["4000"]!.A),
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toBe("Pick a money account for where it landed.");
  });

  it("422s with English when categoryAccountId IS a money (asset) account", async () => {
    const res = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-in",
      date: "2024-03-01",
      amount: "500",
      intoAccountId: String(db.accounts["1000"]!.A),
      categoryAccountId: String(db.accounts["1100"]!.A), // Accounts Receivable — asset
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toBe("Pick a category for where this came from — not a money account.");
  });
});

describe("POST /api/c/entries — money-out", () => {
  it("records a balanced entry with the exact summary sentence", async () => {
    const res = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-out",
      date: "2024-03-05",
      amount: "120",
      fromAccountId: String(db.accounts["1000"]!.A),
      categoryAccountId: String(db.accounts["6000"]!.A),
    });
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(body.summary).toBe("Recorded $120.00 spent on Operating Expense from Cash.");

    const lines = await db.admin.query(
      "SELECT account_id, debit, credit FROM journal_lines WHERE company_id = $1 AND entry_id = $2 ORDER BY id",
      [db.companyA, body.entryId],
    );
    expect(lines.rows).toEqual([
      { account_id: String(db.accounts["6000"]!.A), debit: "120.0000", credit: "0.0000" },
      { account_id: String(db.accounts["1000"]!.A), debit: "0.0000", credit: "120.0000" },
    ]);
  });

  it("422s with English when fromAccountId isn't a money (asset) account", async () => {
    const res = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-out",
      date: "2024-03-05",
      amount: "120",
      fromAccountId: String(db.accounts["2000"]!.A), // Accounts Payable — liability
      categoryAccountId: String(db.accounts["6000"]!.A),
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toBe("Pick a money account for where it came from.");
  });

  it("422s with English when categoryAccountId IS a money (asset) account", async () => {
    const res = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-out",
      date: "2024-03-05",
      amount: "120",
      fromAccountId: String(db.accounts["1000"]!.A),
      categoryAccountId: String(db.accounts["1000"]!.A),
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toBe("Pick a category for what this was spent on — not a money account.");
  });
});

describe("POST /api/c/entries — transfer", () => {
  it("records a balanced entry with thousands-separated amount formatting", async () => {
    const res = await post(app, db.companyA, "/api/c/entries", {
      kind: "transfer",
      date: "2024-03-10",
      amount: "1000",
      fromAccountId: String(db.accounts["1000"]!.A),
      toAccountId: String(db.accounts["1200"]!.A),
    });
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(body.summary).toBe("Moved $1,000.00 from Cash to Due from Affiliate.");

    const lines = await db.admin.query(
      "SELECT account_id, debit, credit FROM journal_lines WHERE company_id = $1 AND entry_id = $2 ORDER BY id",
      [db.companyA, body.entryId],
    );
    expect(lines.rows).toEqual([
      { account_id: String(db.accounts["1200"]!.A), debit: "1000.0000", credit: "0.0000" },
      { account_id: String(db.accounts["1000"]!.A), debit: "0.0000", credit: "1000.0000" },
    ]);
  });

  it("rejects (schema-level, 400) when from/to are the same account", async () => {
    const res = await post(app, db.companyA, "/api/c/entries", {
      kind: "transfer",
      date: "2024-03-10",
      amount: "100",
      fromAccountId: String(db.accounts["1000"]!.A),
      toAccountId: String(db.accounts["1000"]!.A),
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toBe("Pick two different accounts to move money between.");
  });

  it("422s with English when toAccountId isn't a money (asset) account", async () => {
    const res = await post(app, db.companyA, "/api/c/entries", {
      kind: "transfer",
      date: "2024-03-10",
      amount: "100",
      fromAccountId: String(db.accounts["1000"]!.A),
      toAccountId: String(db.accounts["4000"]!.A), // Revenue — not asset
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toBe("Pick a money account to move money into.");
  });
});

describe("POST /api/c/entries — amount validation", () => {
  const base = {
    kind: "money-in", date: "2024-03-01",
    intoAccountId: "", categoryAccountId: "",
  };

  it.each(["12.345", "-5", "abc", "1e3", "0", "0.00", "007", "01.50"])(
    "rejects amount %s",
    async (amount) => {
      const res = await post(app, db.companyA, "/api/c/entries", {
        ...base,
        intoAccountId: String(db.accounts["1000"]!.A),
        categoryAccountId: String(db.accounts["4000"]!.A),
        amount,
      });
      expect(res.statusCode).toBe(400);
    },
  );

  it("accepts a whole-dollar amount, a 2-decimal amount, and a sub-dollar amount", async () => {
    for (const amount of ["75", "75.5", "75.50", "0.50"]) {
      const res = await post(app, db.companyA, "/api/c/entries", {
        ...base,
        intoAccountId: String(db.accounts["1000"]!.A),
        categoryAccountId: String(db.accounts["4000"]!.A),
        amount,
      });
      expect(res.statusCode).toBe(201);
    }
  });
});

describe("POST /api/c/entries — locked period", () => {
  // Locked through January 2024 only — every OTHER test in this file posts
  // March/April 2024 dates, so this lock must not shadow them (period_locks
  // is append-only/company-wide for the rest of the suite's lifetime).
  it("rejects with the locked-period English message and posts nothing", async () => {
    await db.admin.query(
      `INSERT INTO period_locks (company_id, locked_through, created_by) VALUES ($1, '2024-01-31', $2)`,
      [db.companyA, db.userA],
    );
    const res = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-in",
      date: "2024-01-15",
      amount: "10",
      intoAccountId: String(db.accounts["1000"]!.A),
      categoryAccountId: String(db.accounts["4000"]!.A),
    });
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/locked/i);
    expect(res.json().error).not.toMatch(/debit|credit/i);
  });
});

describe("POST /api/c/entries/:id/reverse", () => {
  it("restores balances exactly and returns the exact summary sentence", async () => {
    const create = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-out",
      date: "2024-04-01",
      amount: "77",
      fromAccountId: String(db.accounts["1000"]!.A),
      categoryAccountId: String(db.accounts["6000"]!.A),
    });
    expect(create.statusCode).toBe(201);
    const { entryId, entryNo } = create.json();

    const tbBefore = await db.admin.query<{ code: string; balance: string }>(
      "SELECT code, balance FROM trial_balance($1, NULL)", [db.companyA],
    );
    const cashBefore = tbBefore.rows.find((r) => r.code === "1000")!.balance;
    const expenseBefore = tbBefore.rows.find((r) => r.code === "6000")!.balance;

    const rev = await post(app, db.companyA, `/api/c/entries/${entryId}/reverse`, {});
    expect(rev.statusCode).toBe(201);
    const revBody = rev.json();
    expect(revBody.summary).toBe(`Reversed entry #${entryNo} — every amount put back exactly.`);
    expect(revBody.reversalEntryId).not.toBe(entryId);

    const tbAfter = await db.admin.query<{ code: string; balance: string }>(
      "SELECT code, balance FROM trial_balance($1, NULL)", [db.companyA],
    );
    const cashAfter = tbAfter.rows.find((r) => r.code === "1000")!.balance;
    const expenseAfter = tbAfter.rows.find((r) => r.code === "6000")!.balance;

    // Reversal restores the exact pre-entry balances (subtracting off the
    // 77.00 the original entry moved, computed here only for the assertion,
    // never inside the app code).
    expect(Number(cashAfter)).toBeCloseTo(Number(cashBefore) + 77, 4);
    expect(Number(expenseAfter)).toBeCloseTo(Number(expenseBefore) - 77, 4);

    const lines = await db.admin.query(
      "SELECT account_id, debit, credit FROM journal_lines WHERE company_id = $1 AND entry_id = $2 ORDER BY id",
      [db.companyA, revBody.reversalEntryId],
    );
    expect(lines.rows).toEqual([
      { account_id: String(db.accounts["6000"]!.A), debit: "0.0000", credit: "77.0000" },
      { account_id: String(db.accounts["1000"]!.A), debit: "77.0000", credit: "0.0000" },
    ]);
  });

  it("rejects a double-reverse with the English 'already been reversed' message", async () => {
    const create = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-in",
      date: "2024-04-05",
      amount: "30",
      intoAccountId: String(db.accounts["1000"]!.A),
      categoryAccountId: String(db.accounts["4000"]!.A),
    });
    const { entryId } = create.json();

    const first = await post(app, db.companyA, `/api/c/entries/${entryId}/reverse`, {});
    expect(first.statusCode).toBe(201);

    const second = await post(app, db.companyA, `/api/c/entries/${entryId}/reverse`, {});
    expect(second.statusCode).toBe(409);
    expect(second.json().error).toMatch(/already been reversed/i);
  });

  it("404s (not 500) when the entry belongs to another company (RLS)", async () => {
    const create = await post(app, db.companyA, "/api/c/entries", {
      kind: "money-in",
      date: "2024-04-06",
      amount: "15",
      intoAccountId: String(db.accounts["1000"]!.A),
      categoryAccountId: String(db.accounts["4000"]!.A),
    });
    const { entryId } = create.json();

    const res = await post(app, db.companyB, `/api/c/entries/${entryId}/reverse`, {});
    expect(res.statusCode).toBe(404);
    expect(res.json().error).toMatch(/couldn't find/i);
  });

  it("404s for a made-up entry id", async () => {
    const res = await post(app, db.companyA, "/api/c/entries/999999999/reverse", {});
    expect(res.statusCode).toBe(404);
  });
});
