import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { Prisma } from "@prisma/client";
import prisma from "../config/database";
import {
  invoiceTotalWithVat,
  createInvoice,
  updateInvoice,
  getInvoice,
  listInvoices,
  getInvoiceListTotals,
} from "../services/invoices.service";
import {
  CreateInvoiceSchema,
  UpdateInvoiceSchema,
} from "../schemas/invoices.schema";

// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
// real Decimals here — instead of the old `{ toNumber: () => N }` duck types —
// means a regression in genuine Decimal handling (e.g. swapping `.toNumber()`
// for `Number(decimal)`) is actually caught.

const dec = (n: number | string) => new Prisma.Decimal(n);

// Helper so each test reads as a small declarative table of lines.
function buildInvoice(opts: {
  applyVat: boolean;
  vatRate: number | null;
  currency?: string;
  items: Array<{
    quantity: number | null;
    unit_price: number | null;
    vat_rate: number | null;
  }>;
}) {
  return {
    apply_vat: opts.applyVat,
    vat_rate: opts.vatRate === null ? null : dec(opts.vatRate),
    currency: opts.currency ?? "CZK",
    invoice_items: opts.items.map((i) => ({
      quantity: i.quantity === null ? null : dec(i.quantity),
      unit_price: i.unit_price === null ? null : dec(i.unit_price),
      vat_rate: i.vat_rate === null ? null : dec(i.vat_rate),
    })),
  };
}

describe("invoiceTotalWithVat", () => {
  it("calculates subtotal without VAT when apply_vat is false", () => {
    const inv = buildInvoice({
      applyVat: false,
      vatRate: 21,
      items: [{ quantity: 2, unit_price: 100, vat_rate: 21 }],
    });
    expect(invoiceTotalWithVat(inv)).toBe(200);
  });

  it("rounds each line VAT to 2 decimals before accumulation", () => {
    // 3 items @ 33.33 with 21% VAT
    // Line VAT = 33.33 * 0.21 = 6.9993 -> rounded to 7.00
    // Total VAT = 7.00 * 3 = 21.00
    // Subtotal = 33.33 * 3 = 99.99
    // Total = 99.99 + 21.00 = 120.99
    const inv = buildInvoice({
      applyVat: true,
      vatRate: 21,
      items: Array.from({ length: 3 }, () => ({
        quantity: 1,
        unit_price: 33.33,
        vat_rate: 21,
      })),
    });
    expect(invoiceTotalWithVat(inv)).toBe(120.99);
  });

  it("treats a null quantity (and null unit_price) as 0 for that line", () => {
    // The line is genuinely null here (not 0): quantity?.toNumber() === undefined
    // -> Number(undefined) is NaN -> `|| 0` -> base 0. A second, well-formed
    // line proves the null line contributes nothing while the rest still totals.
    const inv = buildInvoice({
      applyVat: true,
      vatRate: 21,
      items: [
        { quantity: null, unit_price: null, vat_rate: 21 },
        { quantity: 1, unit_price: 100, vat_rate: 21 }, // base 100, vat 21
      ],
    });
    expect(invoiceTotalWithVat(inv)).toBe(121);
  });

  it("returns 0 when every line is null", () => {
    const inv = buildInvoice({
      applyVat: true,
      vatRate: 21,
      items: [{ quantity: null, unit_price: null, vat_rate: null }],
    });
    expect(invoiceTotalWithVat(inv)).toBe(0);
  });

  it("applies mixed per-line vat_rate values, falling back to the invoice rate when a line rate is 0/absent", () => {
    // Line 1: 2 x 100 @ 21% -> base 200, vat 42
    // Line 2: 1 x 50  @ 12% -> base 50,  vat 6
    // Line 3: 1 x 100 @ 0%  -> a line rate of 0 falls through to the invoice
    //         default (15%) per the `|| inv.vat_rate || 21` semantics -> vat 15
    // Subtotal = 350, VAT = 63, Total = 413
    const inv = buildInvoice({
      applyVat: true,
      vatRate: 15,
      items: [
        { quantity: 2, unit_price: 100, vat_rate: 21 },
        { quantity: 1, unit_price: 50, vat_rate: 12 },
        { quantity: 1, unit_price: 100, vat_rate: 0 },
      ],
    });
    expect(invoiceTotalWithVat(inv)).toBe(413);
  });

  it("handles a negative (discount) line correctly", () => {
    // Line 1: 1 x 1000 @ 21% -> base 1000, vat 210
    // Line 2 (discount): 1 x -200 @ 21% -> base -200, vat -42
    // Subtotal = 800, VAT = 168, Total = 968
    const inv = buildInvoice({
      applyVat: true,
      vatRate: 21,
      items: [
        { quantity: 1, unit_price: 1000, vat_rate: 21 },
        { quantity: 1, unit_price: -200, vat_rate: 21 },
      ],
    });
    expect(invoiceTotalWithVat(inv)).toBe(968);
  });
});

/* -------------------------------------------------------------------------- */
/* PUT regression — billing_text must survive UpdateInvoiceSchema             */
/* -------------------------------------------------------------------------- */

// The PUT route does `parseBody(UpdateInvoiceSchema, body)` and hands the
// PARSED data to `updateInvoice`. UpdateInvoiceSchema is a plain z.object,
// which STRIPS unknown keys — so a field missing from the schema is silently
// dropped before the service ever sees it, while the client still gets a
// success response. This block mirrors that exact flow against the real DB.

describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema", () => {
  const invoiceIds: number[] = [];

  afterEach(async () => {
    for (const id of invoiceIds)
      await prisma.invoices.deleteMany({ where: { id } });
    invoiceIds.length = 0;
  });

  it("persists an edited billing_text (the schema must not strip it)", async () => {
    const draft = await createInvoice({ billing_text: "Původní text" });
    invoiceIds.push(draft.id);

    // Mirror the route: raw body -> UpdateInvoiceSchema -> updateInvoice.
    const parsed = UpdateInvoiceSchema.parse({
      billing_text: "Fakturujeme Vám dle objednávky č. 123",
    });
    expect(parsed.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");

    const res = await updateInvoice(draft.id, parsed);
    expect("error" in res).toBe(false);

    const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
    expect(row?.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
  });

  it("clears billing_text when null is sent, and leaves it untouched when absent", async () => {
    const draft = await createInvoice({ billing_text: "Text na PDF" });
    invoiceIds.push(draft.id);

    // Absent from the body -> updateInvoice's `!== undefined` guard skips it.
    await updateInvoice(
      draft.id,
      UpdateInvoiceSchema.parse({ internal_notes: "x" }),
    );
    let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
    expect(row?.billing_text).toBe("Text na PDF");

    // Explicit null -> cleared (the strFields path maps falsy -> null).
    await updateInvoice(
      draft.id,
      UpdateInvoiceSchema.parse({ billing_text: null }),
    );
    row = await prisma.invoices.findUnique({ where: { id: draft.id } });
    expect(row?.billing_text).toBeNull();
  });
});

/* -------------------------------------------------------------------------- */
/* "Obsah" sections + internal-only notes (mirrors issued orders)             */
/* -------------------------------------------------------------------------- */

describe("invoice sections round-trip; dropped `notes` is stripped", () => {
  const invoiceIds: number[] = [];

  afterEach(async () => {
    for (const id of invoiceIds)
      await prisma.invoices.deleteMany({ where: { id } });
    invoiceIds.length = 0;
  });

  it("creates, returns and replaces CZ/EN sections in position order", async () => {
    const parsed = CreateInvoiceSchema.parse({
      billing_text: "Sekce test",
      sections: [
        { title: "Scope", title_cz: "Rozsah", content: "<p>obsah 1</p>" },
        { title: "", title_cz: "Podmínky", content: "<p>obsah 2</p>" },
      ],
    });
    const draft = await createInvoice(parsed);
    invoiceIds.push(draft.id);

    const detail = await getInvoice(draft.id);
    expect(detail?.sections?.length).toBe(2);
    expect(detail?.sections?.[0].title_cz).toBe("Rozsah");
    expect(detail?.sections?.[1].title_cz).toBe("Podmínky");
    expect(detail?.sections?.[0].position).toBe(0);
    expect(detail?.sections?.[1].position).toBe(1);

    // Full-replace on update (same model as items).
    const upd = UpdateInvoiceSchema.parse({
      sections: [
        { title: "Only", title_cz: "Jediná", content: "<p>nový obsah</p>" },
      ],
    });
    const res = await updateInvoice(draft.id, upd);
    expect("error" in res).toBe(false);

    const after = await getInvoice(draft.id);
    expect(after?.sections?.length).toBe(1);
    expect(after?.sections?.[0].title_cz).toBe("Jediná");
    expect(after?.sections?.[0].content).toBe("<p>nový obsah</p>");
  });

  it("sections survive an items-only update untouched (absent = keep)", async () => {
    const draft = await createInvoice(
      CreateInvoiceSchema.parse({
        sections: [{ title: "", title_cz: "Drží", content: "<p>x</p>" }],
      }),
    );
    invoiceIds.push(draft.id);

    await updateInvoice(
      draft.id,
      UpdateInvoiceSchema.parse({
        items: [{ description: "Práce", quantity: 1, unit_price: 100 }],
      }),
    );

    const after = await getInvoice(draft.id);
    expect(after?.sections?.length).toBe(1);
    expect(after?.sections?.[0].title_cz).toBe("Drží");
    expect(after?.items?.length).toBe(1);
  });

  it("silently strips the dropped `notes` key from legacy payloads", async () => {
    // The printed-notes column was dropped (notes are internal-only now) —
    // a legacy client still sending `notes` must not 500 or write anything.
    const parsedCreate = CreateInvoiceSchema.parse({
      notes: "<p>stará poznámka</p>",
      internal_notes: "<p>interní</p>",
    });
    expect("notes" in parsedCreate).toBe(false);

    const draft = await createInvoice(parsedCreate);
    invoiceIds.push(draft.id);
    const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
    expect(row?.internal_notes).toBe("<p>interní</p>");

    const parsedUpdate = UpdateInvoiceSchema.parse({ notes: "x" });
    expect("notes" in parsedUpdate).toBe(false);
    const res = await updateInvoice(draft.id, parsedUpdate);
    expect("error" in res).toBe(false);
  });
});

/* -------------------------------------------------------------------------- */
/* Month filter boundaries — issue_date is @db.Date                           */
/* -------------------------------------------------------------------------- */

// issue_date is @db.Date: Prisma compares a Date filter by its UTC date part.
// The old LOCAL-midnight month bounds (= 22:00/23:00 UTC of the previous day)
// shifted the window a day back — the list/totals included the previous
// month's last day and DROPPED invoices issued on the selected month's LAST
// day. buildInvoiceWhere is shared by listInvoices AND getInvoiceListTotals,
// so both are exercised. Fixtures use the test-only "TST" currency + far-future
// 2098 dates so totals can be asserted exactly without colliding with other
// suites' rows.

describe("listInvoices / getInvoiceListTotals — month filter (@db.Date boundaries)", () => {
  const cleanup = async () => {
    // invoice_items cascade on invoice delete.
    await prisma.invoices.deleteMany({ where: { currency: "TST" } });
  };

  beforeEach(cleanup);
  afterEach(cleanup);

  const listParams = {
    page: 1,
    limit: 100,
    skip: 0,
    sort: "id",
    order: "asc" as const,
    search: "",
  };

  async function createBoundaryFixtures() {
    // Drafts: no invoice number is consumed, but buildInvoiceWhere has no
    // status filter so they appear in the list/totals like any invoice.
    const lastDayJan = await createInvoice({
      status: "draft",
      issue_date: "2098-01-31",
      currency: "TST",
      vat_rate: 21,
      items: [{ quantity: 1, unit_price: 100, vat_rate: 21 }], // total 121
    });
    const firstDayFeb = await createInvoice({
      status: "draft",
      issue_date: "2098-02-01",
      currency: "TST",
      vat_rate: 21,
      items: [{ quantity: 1, unit_price: 200, vat_rate: 21 }], // total 242
    });
    return { lastDayJan, firstDayFeb };
  }

  it("a month's list contains its own LAST day and not the neighbor month's first day", async () => {
    const { lastDayJan, firstDayFeb } = await createBoundaryFixtures();

    const jan = await listInvoices({ ...listParams, month: 1, year: 2098 });
    const janIds = jan.data.map((i) => i.id);
    expect(janIds).toContain(lastDayJan.id);
    expect(janIds).not.toContain(firstDayFeb.id);

    const feb = await listInvoices({ ...listParams, month: 2, year: 2098 });
    const febIds = feb.data.map((i) => i.id);
    expect(febIds).toContain(firstDayFeb.id);
    expect(febIds).not.toContain(lastDayJan.id);
  });

  it("per-currency totals include the month's last day exactly once", async () => {
    await createBoundaryFixtures();

    const jan = await getInvoiceListTotals({ month: 1, year: 2098 });
    const janTst = jan.totals.find((t) => t.currency === "TST");
    // 100 + 21 % VAT — proves the Jan 31 invoice is counted and the
    // Feb 1 invoice (242) is NOT pulled into January.
    expect(janTst?.amount).toBe(121);

    const feb = await getInvoiceListTotals({ month: 2, year: 2098 });
    const febTst = feb.totals.find((t) => t.currency === "TST");
    expect(febTst?.amount).toBe(242);
  });
});
