import { Freight } from "@edr/types";

import { BillingService } from "./billing.service";

/**
 * Minimal in-memory EntityManager stand-in covering the methods
 * `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
 */
function makeManager(savedLines: unknown[]) {
  return {
    create: (_entity: unknown, data: Record<string, unknown>) => data,
    save: (data: Record<string, unknown>) => {
      const row = { id: data.id ?? "gen-1", ...data };
      if (data.invoiceId) savedLines.push(row);
      return Promise.resolve(row);
    },
    query: () => Promise.resolve([{ seq: 0 }]),
    update: jest.fn().mockResolvedValue(undefined),
    findOne: jest.fn().mockResolvedValue(null),
  };
}

function makeEvents() {
  // BillingService emits via both emit() and emitAsync() (the post-commit async
  // listener path) — the mock must provide both.
  return { emit: jest.fn(), emitAsync: jest.fn().mockResolvedValue([]) };
}

function generateInput(overrides: Record<string, unknown> = {}) {
  return {
    source: Freight.InvoiceSource.Booking,
    sourceId: "booking-1",
    type: "prepaid",
    companyId: "company-1",
    companyProfileId: "profile-1",
    currency: "ETB",
    lines: [
      {
        chargeType: "RAIL_FREIGHT",
        description: "Rail freight",
        quantity: 2,
        unitRate: 500,
        amount: 1000,
      },
      {
        chargeType: "HAZARD_SURCHARGE",
        description: "Hazard surcharge",
        quantity: 2,
        unitRate: 250,
        amount: 500,
      },
    ],
    ...overrides,
  };
}

describe("BillingService.generateInvoice", () => {
  let savedLines: unknown[];
  let manager: ReturnType<typeof makeManager>;
  let events: ReturnType<typeof makeEvents>;
  let dataSource: { transaction: jest.Mock; manager: unknown };
  let service: BillingService;

  beforeEach(() => {
    savedLines = [];
    manager = makeManager(savedLines);
    events = makeEvents();
    dataSource = {
      transaction: jest
        .fn()
        .mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
      manager,
    };
    service = new BillingService(
      dataSource as never,
      {} as never,
      {} as never,
      events as never,
      {} as never, // payment
      {} as never, // companies
      {} as never, // invoiceDocuments
    );
  });

  it("creates a PENDING invoice with one line per input line", async () => {
    const invoice = await service.generateInvoice(generateInput());

    expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
    expect(invoice.companyId).toBe("company-1");
    expect(invoice.source).toBe("booking");
    expect(invoice.sourceId).toBe("booking-1");
    expect(invoice.totalAmount).toBe(1500);
    expect(invoice.issuedAt).toBeInstanceOf(Date);
    expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/);
    expect(savedLines).toHaveLength(2);
  });

  it("sums line amounts when no explicit totalAmount is given", async () => {
    const invoice = await service.generateInvoice(
      generateInput({ totalAmount: undefined }),
    );
    expect(invoice.totalAmount).toBe(1500);
  });

  it("leaves issuedAt null for a DRAFT invoice", async () => {
    const invoice = await service.generateInvoice(
      generateInput({ status: Freight.InvoiceStatus.Draft }),
    );
    expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
    expect(invoice.issuedAt).toBeNull();
  });

  it("enlists in a caller's transaction when a manager is passed", async () => {
    await service.generateInvoice(generateInput(), manager as never);
    expect(dataSource.transaction).not.toHaveBeenCalled();
    expect(savedLines).toHaveLength(2);
  });
});

describe("BillingService.markInvoiceAsPaid", () => {
  it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
    const open = {
      id: "inv-1",
      status: Freight.InvoiceStatus.Pending,
      source: "booking",
      sourceId: "booking-1",
      totalAmount: 1500,
      paidAt: null,
    };
    const mg = {
      findOne: jest.fn().mockResolvedValue(open),
      update: jest.fn().mockResolvedValue(undefined),
    };
    const events = makeEvents();
    const service = new BillingService(
      { manager: mg } as never,
      {} as never,
      {} as never,
      events as never,
      {} as never, // payment
      {} as never, // companies
      {} as never, // invoiceDocuments
    );

    await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);

    expect(mg.update).toHaveBeenCalledWith(
      expect.anything(),
      { id: "inv-1" },
      {
        status: Freight.InvoiceStatus.Paid,
        paymentId: "pay-1",
        paidAt: expect.any(Date),
        paidAmount: 1500,
        balanceAmount: 0,
        payments: [
          {
            amount: 1500,
            method: "GATEWAY",
            reference: "pay-1",
            paidAt: expect.any(String),
            metadata: null,
          },
        ],
      },
    );
    expect(events.emitAsync).toHaveBeenCalledWith(
      "booking.invoice.paid",
      expect.objectContaining({
        invoiceId: "inv-1",
        status: Freight.InvoiceStatus.Paid,
        paymentId: "pay-1",
      }),
    );
  });

  it("is a no-op (no event) when the invoice is already paid", async () => {
    const paid = {
      id: "inv-1",
      status: Freight.InvoiceStatus.Paid,
      source: "booking",
    };
    const mg = {
      findOne: jest.fn().mockResolvedValue(paid),
      update: jest.fn().mockResolvedValue(undefined),
    };
    const events = makeEvents();
    const service = new BillingService(
      { manager: mg } as never,
      {} as never,
      {} as never,
      events as never,
      {} as never, // payment
      {} as never, // companies
      {} as never, // invoiceDocuments
    );

    await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);

    expect(mg.update).not.toHaveBeenCalled();
    expect(events.emit).not.toHaveBeenCalled();
    expect(events.emitAsync).not.toHaveBeenCalled();
  });
});

describe("BillingService.recordPayment", () => {
  function serviceFor(invoice: Record<string, unknown> | null) {
    const mg = {
      findOne: jest.fn().mockResolvedValue(invoice),
      update: jest.fn().mockResolvedValue(undefined),
    };
    const events = makeEvents();
    const dataSource = {
      manager: mg,
      transaction: jest
        .fn()
        .mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)),
    };
    const service = new BillingService(
      dataSource as never,
      {} as never,
      {} as never,
      events as never,
      {} as never, // payment
      {} as never, // companies
      {} as never, // invoiceDocuments
    );
    return { service, mg, events };
  }

  const openInvoice = (overrides: Record<string, unknown> = {}) => ({
    id: "inv-1",
    status: Freight.InvoiceStatus.Issued,
    source: "warehouse",
    sourceId: "inv-item-1",
    totalAmount: 1000,
    paidAmount: 0,
    balanceAmount: 1000,
    payments: [],
    paidAt: null,
    ...overrides,
  });

  it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => {
    const { service, mg, events } = serviceFor(openInvoice());

    const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" });

    expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid);
    expect(updated.paidAmount).toBe(400);
    expect(updated.balanceAmount).toBe(600);
    expect(updated.payments).toHaveLength(1);
    expect(mg.update).toHaveBeenCalledWith(
      expect.anything(),
      { id: "inv-1" },
      expect.objectContaining({
        status: Freight.InvoiceStatus.PartiallyPaid,
        paidAmount: 400,
        balanceAmount: 600,
      }),
    );
    expect(events.emit).not.toHaveBeenCalled();
    expect(events.emitAsync).not.toHaveBeenCalled();
  });

  it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
    const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 }));

    const updated = await service.recordPayment("inv-1", { amount: 600 });

    expect(updated.status).toBe(Freight.InvoiceStatus.Paid);
    expect(updated.balanceAmount).toBe(0);
    expect(updated.paidAt).toBeInstanceOf(Date);
    expect(mg.update).toHaveBeenCalled();
    expect(events.emitAsync).toHaveBeenCalledWith(
      "warehouse.invoice.paid",
      expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
    );
  });

  it("rejects a non-positive amount", async () => {
    const { service, mg } = serviceFor(openInvoice());
    await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow();
    expect(mg.update).not.toHaveBeenCalled();
  });

  it("rejects a payment that exceeds the outstanding balance", async () => {
    const { service, mg } = serviceFor(openInvoice());
    await expect(
      service.recordPayment("inv-1", { amount: 1500 }),
    ).rejects.toThrow();
    expect(mg.update).not.toHaveBeenCalled();
  });

  it("rejects payment against a cancelled invoice", async () => {
    const { service, mg } = serviceFor(
      openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
    );
    await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow();
    expect(mg.update).not.toHaveBeenCalled();
  });
});

/**
 * Regression: `expirePayable` (batch settle path, called when a payment window
 * lapses) transitions the invoice to EXPIRED, which locks the row FOR UPDATE.
 * The bug passed `dataSource.manager` (the non-transactional default) into the
 * transition, so runTransition skipped opening a transaction and the lock threw
 * `An open transaction is required for pessimistic lock` — aborting the whole
 * settle pass (the "settle/reserve one booking at a time" symptom). The locked
 * write MUST run inside dataSource.transaction.
 */
describe("BillingService.expirePayable — locked write runs in a transaction", () => {
  const openInvoice = {
    id: "inv-1",
    status: Freight.InvoiceStatus.Pending,
    source: "booking",
    sourceId: "booking-1",
  };

  const build = (lookupResult: Record<string, unknown> | null) => {
    const defaultManager = {
      findOne: jest.fn().mockResolvedValue(lookupResult),
      update: jest.fn().mockResolvedValue(undefined),
    };
    const txManager = {
      findOne: jest.fn().mockResolvedValue(openInvoice),
      update: jest.fn().mockResolvedValue(undefined),
    };
    const transaction = jest
      .fn()
      .mockImplementation((cb: (mg: unknown) => unknown) => cb(txManager));
    const events = makeEvents();
    const service = new BillingService(
      { manager: defaultManager, transaction } as never,
      {} as never,
      {} as never,
      events as never,
      {} as never,
      {} as never,
      {} as never,
    );
    return { service, defaultManager, txManager, transaction };
  };

  it("opens a transaction and runs the pessimistic-lock read on the tx manager", async () => {
    const { service, transaction, txManager, defaultManager } = build(openInvoice);

    await service.expirePayable(
      Freight.InvoiceSource.Booking,
      "booking-1",
      "prepaid",
    );

    expect(transaction).toHaveBeenCalledTimes(1);
    expect(txManager.findOne).toHaveBeenCalledWith(
      expect.anything(),
      expect.objectContaining({ lock: { mode: "pessimistic_write" } }),
    );
    expect(txManager.update).toHaveBeenCalled();
    // The default manager only does the initial lock-free lookup, never a locked read.
    for (const call of defaultManager.findOne.mock.calls) {
      expect(call[1]).not.toHaveProperty("lock");
    }
  });

  it("is a no-op (no transaction) when there is no open invoice", async () => {
    const { service, transaction } = build(null);

    const result = await service.expirePayable(
      Freight.InvoiceSource.Booking,
      "booking-1",
      "prepaid",
    );

    expect(result).toBeNull();
    expect(transaction).not.toHaveBeenCalled();
  });

  it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => {
    const { service, defaultManager } = build({
      ...openInvoice,
      status: Freight.InvoiceStatus.Draft,
    });

    await service.expirePayable(
      Freight.InvoiceSource.Booking,
      "booking-1",
      "prepaid",
    );

    const { where } = defaultManager.findOne.mock.calls[0][1];
    expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
  });
});

describe("BillingService.issuePayable", () => {
  const dueAt = new Date("2026-01-02T00:00:00.000Z");

  const build = (found: Record<string, unknown> | null) => {
    const manager = {
      findOne: jest.fn().mockResolvedValue(found),
      update: jest.fn().mockResolvedValue(undefined),
    };
    const service = new BillingService(
      { manager, transaction: jest.fn() } as never,
      {} as never,
      {} as never,
      makeEvents() as never,
      {} as never,
      {} as never,
      {} as never,
    );
    return { service, manager };
  };

  const issue = (service: BillingService) =>
    service.issuePayable(
      Freight.InvoiceSource.Booking,
      "booking-1",
      dueAt,
      "PREPAID",
    );

  it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => {
    const { service, manager } = build({
      id: "inv-1",
      invoiceNumber: "INV-20260101-00001",
      status: Freight.InvoiceStatus.Draft,
      issuedAt: null,
    });

    const result = await issue(service);

    const patch = manager.update.mock.calls[0][2];
    expect(patch.status).toBe(Freight.InvoiceStatus.Pending);
    expect(patch.dueAt).toBe(dueAt);
    expect(patch.issuedAt).toBeInstanceOf(Date);
    expect(result?.status).toBe(Freight.InvoiceStatus.Pending);
  });

  it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => {
    const { service, manager } = build(null);

    await issue(service);

    const { where } = manager.findOne.mock.calls[0][1];
    expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
  });

  it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => {
    const issuedAt = new Date("2026-01-01T00:00:00.000Z");
    const { service, manager } = build({
      id: "inv-1",
      invoiceNumber: "INV-20260101-00001",
      status: Freight.InvoiceStatus.Pending,
      issuedAt,
    });

    const result = await issue(service);

    expect(manager.update.mock.calls[0][2]).toEqual({ dueAt });
    expect(result?.issuedAt).toBe(issuedAt);
  });

  it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => {
    const { service, manager } = build(null);

    await expect(issue(service)).resolves.toBeNull();
    expect(manager.update).not.toHaveBeenCalled();
  });
});
