import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import {
  previewIssuedOrderNumber,
  previewInvoiceNumber,
  previewOfferNumber,
} from "../services/numbering.service";
import {
  createIssuedOrder,
  updateIssuedOrder,
  deleteIssuedOrder,
} from "../services/issued-orders.service";
import {
  createInvoice,
  updateInvoice,
  deleteInvoice,
} from "../services/invoices.service";
import {
  createOffer,
  updateOffer,
  deleteOffer,
  invalidateOffer,
  getOffer,
  listOffers,
} from "../services/offers.service";
import quotationsRoutes from "../routes/admin/quotations";

// Track every row we create so the suite leaves the (real) app_test DB clean.
const issuedOrderIds: number[] = [];
const invoiceIds: number[] = [];
const offerIds: number[] = [];
const customerIds: number[] = [];
const linkedOrderIds: number[] = [];

afterEach(async () => {
  for (const id of issuedOrderIds)
    await prisma.issued_orders.deleteMany({ where: { id } });
  for (const id of invoiceIds)
    await prisma.invoices.deleteMany({ where: { id } });
  // Linked orders FIRST — orders.quotation_id is a Restrict FK on quotations.
  for (const id of linkedOrderIds)
    await prisma.orders.deleteMany({ where: { id } });
  for (const id of offerIds)
    await prisma.quotations.deleteMany({ where: { id } });
  for (const id of customerIds)
    await prisma.customers.deleteMany({ where: { id } });
  issuedOrderIds.length = 0;
  invoiceIds.length = 0;
  linkedOrderIds.length = 0;
  offerIds.length = 0;
  customerIds.length = 0;
  // Reset every sequence this suite may have touched so preview values are
  // stable across tests and we don't leak consumed numbers into other files.
  await prisma.number_sequences.deleteMany({
    where: { type: { in: ["issued_order", "invoice", "offer"] } },
  });
});

/** createOffer + narrow the token union + track cleanup. */
async function mkOffer(body: Record<string, unknown> = {}) {
  const res = await createOffer(body);
  if ("error" in res) throw new Error(`createOffer failed: ${res.error}`);
  offerIds.push(res.id);
  return res;
}

/* -------------------------------------------------------------------------- */
/* Issued orders                                                              */
/* -------------------------------------------------------------------------- */

describe("issued-order drafts — deferred numbering", () => {
  it("a draft has no number and does NOT consume the sequence", async () => {
    const before = (await previewIssuedOrderNumber()).number;
    const draft = await createIssuedOrder({}); // defaults to draft
    if ("error" in draft)
      throw new Error(`createIssuedOrder failed: ${draft.error}`);
    issuedOrderIds.push(draft.id);
    expect(draft.status).toBe("draft");
    expect(draft.po_number).toBeNull();
    // Preview is unchanged → the sequence was not consumed.
    const after = (await previewIssuedOrderNumber()).number;
    expect(after).toBe(before);
  });

  it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
    const expected = (await previewIssuedOrderNumber()).number;
    const draft = await createIssuedOrder({});
    if ("error" in draft)
      throw new Error(`createIssuedOrder failed: ${draft.error}`);
    issuedOrderIds.push(draft.id);

    const res = await updateIssuedOrder(draft.id, { status: "sent" });
    expect("error" in res).toBe(false);

    const row = await prisma.issued_orders.findUnique({
      where: { id: draft.id },
    });
    expect(row?.status).toBe("sent");
    expect(row?.po_number).toBe(expected);
    expect("po_number" in res && res.po_number).toBe(expected);
  });

  it("finalizing is idempotent — re-finalizing does not re-number", async () => {
    const draft = await createIssuedOrder({});
    if ("error" in draft)
      throw new Error(`createIssuedOrder failed: ${draft.error}`);
    issuedOrderIds.push(draft.id);

    await updateIssuedOrder(draft.id, { status: "sent" });
    const first = (
      await prisma.issued_orders.findUnique({ where: { id: draft.id } })
    )?.po_number;
    const seqAfterFirst = await prisma.number_sequences.findFirst({
      where: { type: "issued_order" },
    });

    // draft -> sent is no longer valid (already sent); the number must not change.
    // Drive a second finalize-style update that keeps status sent.
    await updateIssuedOrder(draft.id, { status: "sent" });
    const second = (
      await prisma.issued_orders.findUnique({ where: { id: draft.id } })
    )?.po_number;
    const seqAfterSecond = await prisma.number_sequences.findFirst({
      where: { type: "issued_order" },
    });

    expect(second).toBe(first);
    expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
  });

  it("two drafts coexist with null numbers (no unique violation)", async () => {
    const a = await createIssuedOrder({});
    const b = await createIssuedOrder({});
    if ("error" in a) throw new Error(`createIssuedOrder failed: ${a.error}`);
    if ("error" in b) throw new Error(`createIssuedOrder failed: ${b.error}`);
    issuedOrderIds.push(a.id, b.id);
    expect(a.po_number).toBeNull();
    expect(b.po_number).toBeNull();
  });

  it("deleting a draft releases nothing (no number was consumed)", async () => {
    const before = (await previewIssuedOrderNumber()).number;
    const draft = await createIssuedOrder({});
    if ("error" in draft)
      throw new Error(`createIssuedOrder failed: ${draft.error}`);
    await deleteIssuedOrder(draft.id);
    const after = (await previewIssuedOrderNumber()).number;
    expect(after).toBe(before);
  });
});

/* -------------------------------------------------------------------------- */
/* Invoices                                                                   */
/* -------------------------------------------------------------------------- */

describe("invoice drafts — deferred numbering", () => {
  it("a draft has no number and does NOT consume the sequence", async () => {
    const before = (await previewInvoiceNumber()).number;
    const draft = await createInvoice({});
    invoiceIds.push(draft.id);
    expect(draft.status).toBe("draft");
    expect(draft.invoice_number).toBeNull();
    const after = (await previewInvoiceNumber()).number;
    expect(after).toBe(before);
  });

  it("finalizing a draft (draft -> issued) assigns the next sequence number", async () => {
    const expected = (await previewInvoiceNumber()).number;
    const draft = await createInvoice({});
    invoiceIds.push(draft.id);

    const res = await updateInvoice(draft.id, { status: "issued" });
    expect("error" in res).toBe(false);

    const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
    expect(row?.status).toBe("issued");
    expect(row?.invoice_number).toBe(expected);
    expect("invoice_number" in res && res.invoice_number).toBe(expected);
  });

  it("finalizing twice does not re-number / double-consume", async () => {
    const draft = await createInvoice({});
    invoiceIds.push(draft.id);

    await updateInvoice(draft.id, { status: "issued" });
    const first = (
      await prisma.invoices.findUnique({ where: { id: draft.id } })
    )?.invoice_number;
    const seqAfterFirst = await prisma.number_sequences.findFirst({
      where: { type: "invoice" },
    });

    // issued -> issued is a no-op status-wise; the number must stay put.
    await updateInvoice(draft.id, { status: "issued" });
    const second = (
      await prisma.invoices.findUnique({ where: { id: draft.id } })
    )?.invoice_number;
    const seqAfterSecond = await prisma.number_sequences.findFirst({
      where: { type: "invoice" },
    });

    expect(second).toBe(first);
    expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
  });

  it("two invoice drafts coexist with null numbers", async () => {
    const a = await createInvoice({});
    const b = await createInvoice({});
    invoiceIds.push(a.id, b.id);
    expect(a.invoice_number).toBeNull();
    expect(b.invoice_number).toBeNull();
  });

  it("deleting a draft releases nothing", async () => {
    const before = (await previewInvoiceNumber()).number;
    const draft = await createInvoice({});
    await deleteInvoice(draft.id);
    const after = (await previewInvoiceNumber()).number;
    expect(after).toBe(before);
  });
});

/* -------------------------------------------------------------------------- */
/* Offers                                                                     */
/* -------------------------------------------------------------------------- */

describe("offer drafts — deferred numbering", () => {
  it("a draft has no number and does NOT consume the sequence", async () => {
    const before = await previewOfferNumber();
    const draft = await createOffer({});
    if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
    offerIds.push(draft.id);
    expect(draft.status).toBe("draft");
    expect(draft.quotation_number).toBeNull();
    const after = await previewOfferNumber();
    expect(after).toBe(before);
  });

  it("finalizing a draft (draft -> active) assigns the next sequence number", async () => {
    const expected = await previewOfferNumber();
    const draft = await createOffer({});
    if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
    offerIds.push(draft.id);

    const res = await updateOffer(draft.id, { status: "active" });
    expect("error" in res).toBe(false);

    const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
    expect(row?.status).toBe("active");
    expect(row?.quotation_number).toBe(expected);
    expect("quotation_number" in res && res.quotation_number).toBe(expected);
  });

  it("finalizing twice does not re-number / double-consume", async () => {
    const draft = await createOffer({});
    if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
    offerIds.push(draft.id);

    await updateOffer(draft.id, { status: "active" });
    const first = (
      await prisma.quotations.findUnique({ where: { id: draft.id } })
    )?.quotation_number;
    const seqAfterFirst = await prisma.number_sequences.findFirst({
      where: { type: "offer" },
    });

    // active -> active is a no-op; the number must stay put.
    await updateOffer(draft.id, { status: "active" });
    const second = (
      await prisma.quotations.findUnique({ where: { id: draft.id } })
    )?.quotation_number;
    const seqAfterSecond = await prisma.number_sequences.findFirst({
      where: { type: "offer" },
    });

    expect(second).toBe(first);
    expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
  });

  it("two offer drafts coexist with null numbers", async () => {
    const a = await createOffer({});
    const b = await createOffer({});
    if ("error" in a) throw new Error(`createOffer failed: ${a.error}`);
    if ("error" in b) throw new Error(`createOffer failed: ${b.error}`);
    offerIds.push(a.id, b.id);
    expect(a.quotation_number).toBeNull();
    expect(b.quotation_number).toBeNull();
  });

  it("an offer created already-finalized (status active) numbers immediately", async () => {
    const expected = await previewOfferNumber();
    const offer = await createOffer({ status: "active" });
    if ("error" in offer) throw new Error(`createOffer failed: ${offer.error}`);
    offerIds.push(offer.id);
    expect(offer.quotation_number).toBe(expected);
  });

  it("deleting a draft releases nothing", async () => {
    const before = await previewOfferNumber();
    const draft = await createOffer({});
    if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
    await deleteOffer(draft.id);
    const after = await previewOfferNumber();
    expect(after).toBe(before);
  });
});

/* -------------------------------------------------------------------------- */
/* Offers — status transition table (service)                                 */
/* -------------------------------------------------------------------------- */

describe("offer status transitions (service)", () => {
  it("rejects draft -> ordered so a numberless 'ordered' offer can never exist", async () => {
    const draft = await mkOffer({});
    const res = await updateOffer(draft.id, { status: "ordered" });
    expect("error" in res && res.error).toBe("invalid_transition");
    const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
    expect(row!.status).toBe("draft");
    expect(row!.quotation_number).toBeNull();
  });

  it("allows active -> invalidated via update", async () => {
    const offer = await mkOffer({ status: "active" });
    const res = await updateOffer(offer.id, { status: "invalidated" });
    expect("error" in res).toBe(false);
    const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
    expect(row!.status).toBe("invalidated");
  });

  it("invalidateOffer respects the table: draft rejected, ordered ok, terminal stays terminal", async () => {
    const draft = await mkOffer({});
    const rejected = await invalidateOffer(draft.id);
    expect("error" in rejected && rejected.error).toBe("invalid_transition");

    const ordered = await mkOffer({ status: "ordered" });
    const ok = await invalidateOffer(ordered.id);
    expect("error" in ok).toBe(false);

    // invalidated is terminal — a second invalidate is rejected too.
    const again = await invalidateOffer(ordered.id);
    expect("error" in again && again.error).toBe("invalid_transition");
  });

  it("getOffer exposes valid_transitions computed from the current status", async () => {
    const draft = await mkOffer({});
    expect((await getOffer(draft.id))?.valid_transitions).toEqual(["active"]);

    const active = await mkOffer({ status: "active" });
    expect((await getOffer(active.id))?.valid_transitions).toEqual([
      "ordered",
      "invalidated",
    ]);
  });
});

/* -------------------------------------------------------------------------- */
/* Offers — editable-state guard (service)                                    */
/* -------------------------------------------------------------------------- */

describe("offer editable-state guard (service)", () => {
  it("explicitly rejects a field edit on an ordered offer (not silently dropped)", async () => {
    const offer = await mkOffer({ status: "ordered", project_code: "PŮVODNÍ" });
    const res = await updateOffer(offer.id, { project_code: "ZMĚNA" });
    expect("error" in res && res.error).toBe("not_editable");
    const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
    expect(row!.project_code).toBe("PŮVODNÍ");
  });

  it("rejects a field edit on an invalidated offer", async () => {
    const offer = await mkOffer({ status: "invalidated" });
    const res = await updateOffer(offer.id, { scope_title: "X" });
    expect("error" in res && res.error).toBe("not_editable");
  });

  it("still allows the status-only ordered -> invalidated payload", async () => {
    const offer = await mkOffer({ status: "ordered" });
    const res = await updateOffer(offer.id, { status: "invalidated" });
    expect("error" in res).toBe(false);
    const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
    expect(row!.status).toBe("invalidated");
  });
});

/* -------------------------------------------------------------------------- */
/* Offers — customer FK handling (service)                                    */
/* -------------------------------------------------------------------------- */

describe("offer customer FK handling (service)", () => {
  async function mkCustomer() {
    const c = await prisma.customers.create({
      data: { name: "drafts_test_zákazník" },
    });
    customerIds.push(c.id);
    return c;
  }

  it("create rejects a nonexistent customer with a clean token (no P2003 500)", async () => {
    const res = await createOffer({ customer_id: 99999999 });
    expect("error" in res && res.error).toBe("customer_not_found");
  });

  it("update rejects a nonexistent customer with a clean token", async () => {
    const offer = await mkOffer({});
    const res = await updateOffer(offer.id, { customer_id: 99999999 });
    expect("error" in res && res.error).toBe("customer_not_found");
  });

  it("explicit null CLEARS the customer (the old Number(null) -> 0 bug)", async () => {
    const customer = await mkCustomer();
    const offer = await mkOffer({ customer_id: customer.id });
    expect(offer.customer_id).toBe(customer.id);

    const res = await updateOffer(offer.id, { customer_id: null });
    expect("error" in res).toBe(false);
    const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
    expect(row!.customer_id).toBeNull();
  });
});

/* -------------------------------------------------------------------------- */
/* Offers — deterministic list ordering                                       */
/* -------------------------------------------------------------------------- */

describe("listOffers deterministic ordering", () => {
  it("tiebreaks same-created_at rows by id (stable in both directions)", async () => {
    const MARK = `TIEBREAK-${Date.now()}`;
    const ids: number[] = [];
    for (let i = 0; i < 3; i++) {
      const o = await mkOffer({ project_code: MARK });
      ids.push(o.id);
    }
    // created_at is second-precision; pin all three to the SAME timestamp so
    // only the id tiebreak can order them.
    await prisma.quotations.updateMany({
      where: { id: { in: ids } },
      data: { created_at: new Date("2026-01-15T10:00:00") },
    });

    const base = { page: 1, limit: 10, skip: 0, search: MARK };
    const desc = await listOffers({
      ...base,
      sort: "created_at",
      order: "desc",
    });
    expect(desc.data.map((o: { id: number }) => o.id)).toEqual(
      [...ids].sort((a, b) => b - a),
    );

    const asc = await listOffers({ ...base, sort: "created_at", order: "asc" });
    expect(asc.data.map((o: { id: number }) => o.id)).toEqual(
      [...ids].sort((a, b) => a - b),
    );
  });
});

/* -------------------------------------------------------------------------- */
/* Offers — route-level hardening (Czech messages + status codes)             */
/* -------------------------------------------------------------------------- */

let app: ReturnType<typeof Fastify> | null = null;
let adminToken = "";

beforeAll(async () => {
  app = Fastify({ logger: false });
  await app.register(quotationsRoutes, { prefix: "/api/admin/offers" });

  const admin = await prisma.users.findFirst({
    where: { roles: { name: "admin" } },
  });
  if (!admin) throw new Error("Test setup: admin user not found");
  adminToken = jwt.sign(
    { sub: admin.id, username: admin.username, role: "admin" },
    config.jwt.secret,
    { expiresIn: "15m" },
  );
});

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

describe("offers routes — hardening (HTTP)", () => {
  const auth = () => ({ Authorization: `Bearer ${adminToken}` });

  it("PUT rejects a field edit on an ordered offer with the explicit Czech 400", async () => {
    const offer = await mkOffer({ status: "ordered" });
    const res = await app!.inject({
      method: "PUT",
      url: `/api/admin/offers/${offer.id}`,
      headers: auth(),
      payload: { project_code: "ZMĚNA" },
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toBe("Nabídku v tomto stavu nelze upravovat");
  });

  it("PUT rejects draft -> ordered with the transition message", async () => {
    const draft = await mkOffer({});
    const res = await app!.inject({
      method: "PUT",
      url: `/api/admin/offers/${draft.id}`,
      headers: auth(),
      payload: { status: "ordered" },
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toBe(
      'Neplatný přechod stavu z "draft" na "ordered"',
    );
  });

  it("POST 400s a 501-char item description (schema cap, not a DB 500)", async () => {
    const res = await app!.inject({
      method: "POST",
      url: "/api/admin/offers",
      headers: auth(),
      payload: { items: [{ description: "x".repeat(501) }] },
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().success).toBe(false);
  });

  it("POST 400s a nonexistent customer with 'Zákazník nenalezen'", async () => {
    const res = await app!.inject({
      method: "POST",
      url: "/api/admin/offers",
      headers: auth(),
      payload: { customer_id: 99999999 },
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toBe("Zákazník nenalezen");
  });

  it("DELETE returns a Czech 409 when the offer is linked to an order (real Restrict FK)", async () => {
    const offer = await mkOffer({ status: "active" });
    const order = await prisma.orders.create({
      data: {
        order_number: `DRAFTSLINK-${Date.now()}`,
        quotation_id: offer.id,
      },
    });
    linkedOrderIds.push(order.id);

    const res = await app!.inject({
      method: "DELETE",
      url: `/api/admin/offers/${offer.id}`,
      headers: auth(),
    });
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toBe(
      "Nabídku nelze smazat, je navázána na objednávku nebo projekt",
    );
    // The offer survived the rejected delete.
    expect(
      await prisma.quotations.findUnique({ where: { id: offer.id } }),
    ).not.toBeNull();
  });

  it("PUT writes an audit row carrying oldValues and newValues ('koncept' fallback)", async () => {
    const draft = await mkOffer({ project_code: "PŘED" });
    const res = await app!.inject({
      method: "PUT",
      url: `/api/admin/offers/${draft.id}`,
      headers: auth(),
      payload: { project_code: "PO" },
    });
    expect(res.statusCode).toBe(200);

    const audit = await prisma.audit_logs.findFirst({
      where: {
        entity_type: "quotation",
        entity_id: draft.id,
        action: "update",
      },
      orderBy: { id: "desc" },
    });
    expect(audit).not.toBeNull();
    expect(audit!.old_values).toBeTruthy();
    expect(audit!.new_values).toBeTruthy();
    expect(JSON.parse(audit!.old_values!).project_code).toBe("PŘED");
    expect(JSON.parse(audit!.new_values!).project_code).toBe("PO");
    // Unnumbered drafts read "koncept", never "null".
    expect(audit!.description).toContain("koncept");
    expect(audit!.description).not.toContain("null");
  });
});
