import { Harness, TenantClient } from './harness';

const today = new Date().toISOString().slice(0, 10);
const monthStart = `${today.slice(0, 7)}-01`;
const minusDays = (d: number) => new Date(Date.now() - d * 86400000).toISOString().slice(0, 10);

describe('3PL billing: contract -> event-driven charges -> storage snapshots -> invoice', () => {
  const h = new Harness();
  let t: TenantClient;
  let storerId: string;
  let contractId: string;
  let tariffs: Record<string, string> = {};

  beforeAll(async () => {
    await h.start();
    t = await h.tenant('bil');
    storerId = (await t.standardWorld()).storer.id;
  });
  afterAll(() => h.stop());

  it('creates a contract with a tariff card and activates it', async () => {
    const c = (await t.post('/billing/contracts', { storerId, name: 'Acme 3PL 2026', currency: 'KWD', storageMethod: 'WEEKLY_CBM_OPENING_PLUS_IN', validFrom: '2026-01-01', taxRatePct: 0, minimumMonthly: '50' }).expect(201)).body;
    contractId = c.id;
    const defs = [
      { code: 'UNLOAD-40', group: 'HANDLING_IN', description: 'Offloading 40ft container', chargeUnit: 'PER_CONTAINER_40', rate: '40', trigger: 'ASN_CLOSED' },
      { code: 'IN-CBM', group: 'HANDLING_IN', description: 'Inbound handling per CBM', chargeUnit: 'PER_CBM', rate: '1.2', trigger: 'ASN_CLOSED', minimumCharge: '5' },
      { code: 'OUT-ORDER', group: 'HANDLING_OUT', description: 'Order processing fee', chargeUnit: 'PER_ORDER', rate: '2.5', trigger: 'ORDER_SHIPPED' },
      { code: 'OUT-CASE', group: 'HANDLING_OUT', description: 'Case picking', chargeUnit: 'PER_CASE', rate: '0.25', trigger: 'ORDER_SHIPPED', tiers: [{ upTo: 100, rate: 0.25 }, { upTo: null, rate: 0.2 }] },
      { code: 'STO-CBM-WK', group: 'STORAGE', description: 'Storage per CBM per week', chargeUnit: 'PER_CBM_WEEK', rate: '1.5', trigger: 'STORAGE_PERIOD' },
      { code: 'VAS-LABEL', group: 'VAS', description: 'Labelling per piece', chargeUnit: 'PER_PIECE', rate: '0.05', trigger: 'MANUAL' },
    ];
    for (const d of defs) {
      const tr = (await t.post(`/billing/contracts/${contractId}/tariffs`, d).expect(201)).body;
      tariffs[d.code] = tr.id;
    }
    const active = (await t.patch(`/billing/contracts/${contractId}`, { status: 'ACTIVE' }).expect(200)).body;
    expect(active.status).toBe('ACTIVE');
  });

  it('captures inbound charges from asn.closed (container + CBM with minimum charge)', async () => {
    const { asn } = await t.receiveStandardAsn(storerId);
    await t.post(`/inbound/asns/${asn.id}/close`, { forceCloseShort: true }).expect(201);
    await h.drainOutbox();
    const charges = (await t.get('/billing/charges', { storerId }).expect(200)).body.items;
    const codes = charges.map((c: { tariffCode: string }) => c.tariffCode).sort();
    expect(codes).toEqual(['IN-CBM', 'UNLOAD-40']);
    const unload = charges.find((c: { tariffCode: string }) => c.tariffCode === 'UNLOAD-40');
    expect(Number(unload.amount)).toBe(40);
    const cbm = charges.find((c: { tariffCode: string }) => c.tariffCode === 'IN-CBM');
    // 108 units x 0.002 cbm = 0.216 cbm x 1.2 = 0.2592 < minimum 5 -> minimum applied
    expect(Number(cbm.amount)).toBe(5);
    expect(cbm.detail?.minimumApplied).toBe(true);
  });

  it('is idempotent when the same event is dispatched twice', async () => {
    const before = (await t.get('/billing/charges', { storerId }).expect(200)).body.total;
    await h.drainOutbox();
    const after = (await t.get('/billing/charges', { storerId }).expect(200)).body.total;
    expect(after).toBe(before);
  });

  it('captures outbound charges per order and per case equivalent', async () => {
    const o = (await t.post('/outbound/orders', { warehouseId: t.warehouseId, storerId, externalRef: 'SO-B1', shipTo: { name: 'Store A' }, lines: [{ skuCode: 'WIDGET-1', orderedQty: '2', packLevel: 'CASE' }] }).expect(201)).body;
    const wave = (await t.post('/outbound/waves', { warehouseId: t.warehouseId, strategy: 'BY_ORDER', orderIds: [o.id], allocateNow: true, releaseNow: true }).expect(201)).body;
    expect(wave.shortLines).toBe(0);
    const task = (await t.post('/tasks/next', { warehouseId: t.warehouseId, types: ['PICK'] }).expect(201)).body.task;
    await t.post(`/tasks/${task.id}/confirm`, { fromLocationCode: task.fromLocationCode, barcode: 'CS-WIDGET-1', toLpnNumber: 'TOTE-B1' }).expect(201);
    const sh = (await t.post('/outbound/packing/start', { orderId: o.id }).expect(201)).body;
    await t.post('/outbound/packing/carton', { shipmentId: sh.id, lines: [{ orderLineId: sh.lines[0].id, qty: String(sh.lines[0].toPack), lotNumber: 'L1', expiryDate: '2027-01-01' }], weightKg: '12' }).expect(201);
    await t.post('/outbound/packing/complete', { shipmentId: sh.id }).expect(201);
    const load = (await t.post('/outbound/loads', { warehouseId: t.warehouseId, vehicleNo: 'KWT 1', carrierName: 'Own fleet', shipmentIds: [sh.id] }).expect(201)).body;
    await t.post(`/outbound/loads/${load.id}/shipments/${sh.id}/loaded`).expect(201);
    await t.post(`/outbound/loads/${load.id}/dispatch`, { sealNo: 'S1' }).expect(201);
    await h.drainOutbox();
    const charges = (await t.get('/billing/charges', { storerId }).expect(200)).body.items;
    const order = charges.find((c: { tariffCode: string }) => c.tariffCode === 'OUT-ORDER');
    const cases = charges.find((c: { tariffCode: string }) => c.tariffCode === 'OUT-CASE');
    expect(Number(order.amount)).toBe(2.5);
    expect(Number(cases.quantity)).toBe(2); // 24 units / 12 per case
    expect(Number(cases.amount)).toBe(0.5); // tier 1 rate 0.25
  });

  it('manual VAS charge, waiver and storage snapshots with the weekly formula', async () => {
    const vas = (await t.post('/billing/charges', { storerId, warehouseId: t.warehouseId, tariffId: tariffs['VAS-LABEL'], quantity: '200', chargeDate: today, description: 'Labelling 200 pcs' }).expect(201)).body;
    expect(Number(vas.amount)).toBe(10);
    const waived = (await t.post(`/billing/charges/${vas.id}/waive`, { reason: 'goodwill' }).expect(201)).body;
    expect(waived.status).toBe('WAIVED');

    for (let d = 8; d >= 0; d--) await t.post('/billing/storage/snapshot', { date: minusDays(d) }).expect(201);
    const report = (await t.get('/billing/storage/report', { storerId, from: minusDays(8), to: today, granularity: 'WEEK' }).expect(200)).body;
    expect(report.length).toBeGreaterThanOrEqual(1);
    for (const w of report) {
      expect(Number(w.closingCbm)).toBeGreaterThanOrEqual(0);
      expect(w.key).toMatch(/^\d{4}-W\d{2}$/);
    }
  });

  it('runs the invoice, applies the monthly minimum, issues it with a PDF and marks charges invoiced', async () => {
    const inv = (await t.post('/billing/invoices/run', { storerId, periodStart: monthStart, periodEnd: today }).expect(201)).body;
    expect(inv.invoiceNumber).toMatch(/^INV-/);
    expect(inv.status).toBe('DRAFT');
    const groups = new Set(inv.lines.map((l: { group: string }) => l.group));
    expect(groups.has('HANDLING_IN')).toBe(true);
    expect(groups.has('HANDLING_OUT')).toBe(true);
    expect(Number(inv.total)).toBeGreaterThanOrEqual(50); // minimumMonthly
    const issued = (await t.post(`/billing/invoices/${inv.id}/issue`, { sendTo: ['finance@acme.test'] }).expect(201)).body;
    expect(['ISSUED', 'SENT']).toContain(issued.status);
    expect(issued.documentId).toBeTruthy();
    const pdf = await t.get(`/documents/${issued.documentId}/download`).expect(200);
    expect(pdf.headers['content-type']).toContain('application/pdf');
    const charges = (await t.get('/billing/charges', { storerId }).expect(200)).body.items;
    const statuses = new Set(charges.map((c: { status: string }) => c.status));
    expect(statuses.has('PENDING')).toBe(false);
    expect(statuses.has('INVOICED')).toBe(true);
  });

  it('records a payment and a credit note', async () => {
    const inv = (await t.get('/billing/invoices', { storerId }).expect(200)).body.items[0];
    const paid = (await t.post(`/billing/invoices/${inv.id}/payments`, { amount: '10', paidAt: today, reference: 'TRX-1' }).expect(201)).body;
    expect(Number(paid.paidAmount)).toBe(10);
    expect(paid.status).toBe('PARTIALLY_PAID');
    const cn = (await t.post(`/billing/invoices/${inv.id}/credit-notes`, { amount: '5', reason: 'damaged goods' }).expect(201)).body;
    expect(cn.creditNumber).toMatch(/^CRN-/);
  });
});
