/**
 * DC-054 + DC-057 stripe-license-bridge tests.
 *
 * Strategy: no live network, no live Stripe SDK. We use `jest.mock` to
 * substitute license-keygen + nodemailer before the bridge loads, drive
 * handleWebhook() with crafted raw bodies + signatures.
 *
 * Coverage:
 *   - Signature validation (pass / missing / wrong / out-of-tolerance)
 *   - JSON parse failure
 *   - Duplicate event-id → 200 idempotent
 *   - Two different events for the SAME session → single license (layer-2 idempotency)
 *   - License persisted BEFORE email (crash-safety)
 *   - Email failure → markDeliveryFailed → returns 500 → customer can retrieve via lookup
 *   - Retry from pending_email delivers the SAME code
 *   - Concurrent lease (busy) returns 409
 *   - Catalog resolution: missing productId → 400; unknown productId → 400;
 *     product-not-configured → 400
 *   - Lookup endpoint: not_found, processing, pending_email, delivered, expired TTL
 *   - Layer-1 + Layer-2 idempotency under Stripe retry
 */

// jest.mock must be hoisted before any require.
jest.mock('../../license-keygen', () => {
  const crypto = require('crypto');
  let calls = 0;
  return {
    VALID_DURATIONS: [30, 90, 180, 365],
    loadSecret: () => 'mock-license-secret-' + crypto.randomBytes(8).toString('hex'),
    generateCodes: jest.fn(({ durationDays, count }) => {
      calls++;
      const codes = [];
      for (let i = 0; i < count; i++) {
        codes.push({
          code: `DC-TEST-${durationDays}D-${crypto.randomBytes(4).toString('hex').toUpperCase()}`,
          codeId: `codeid_${Date.now()}_${i}_${calls}`,
        });
      }
      return codes;
    }),
    __resetGenerateCalls() { calls = 0; },
    __getGenerateCalls() { return calls; },
  };
});

jest.mock('nodemailer', () => ({
  createTransport: jest.fn(() => ({
    sendMail: jest.fn(),
  })),
}));

const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');

// Set up isolated tmp dirs BEFORE requiring the bridge (it captures paths at require time).
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dc057-bridge-'));
process.env.STRIPE_BRIDGE_STATE_DIR = TMP;
process.env.STRIPE_BRIDGE_EVENTS_FILE = path.join(TMP, 'stripe-events.json');
process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE = path.join(TMP, 'stripe-fulfillments.json');
// Use a unique webhook secret so tests don't pollute each other.
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_' + crypto.randomBytes(8).toString('hex');
// Configure all Stripe Prices so catalog.getConfiguredProducts() returns them.
process.env.STRIPE_PRICE_PRO_30D = 'price_30d_test';
process.env.STRIPE_PRICE_PRO_90D = 'price_90d_test';
process.env.STRIPE_PRICE_PRO_180D = 'price_180d_test';
process.env.STRIPE_PRICE_PRO_365D = 'price_365d_test';
// Disable SMTP so the bridge falls back to dev-console unless a test
// explicitly injects nodemailer.
delete process.env.SMTP_HOST;
delete process.env.SMTP_FROM;

const licenseKeygenMock = require('../../license-keygen');
const nodemailerMock = require('nodemailer');
const bridge = require('../../scripts/stripe-license-bridge');
const catalog = require('../../src/billing/catalog');
const { createFulfillmentStore } = require('../../src/billing/fulfillment-store');

afterEach(() => {
  delete process.env.SMTP_HOST;
  delete process.env.SMTP_FROM;
  licenseKeygenMock.__resetGenerateCalls();
  // Reset nodemailer.sendMail mock implementations between tests.
  nodemailerMock.createTransport.mockClear();
});

// Helper: build a signed Stripe webhook payload.
function buildSignedPayload(body, opts = {}) {
  const secret = opts.secret || process.env.STRIPE_WEBHOOK_SECRET;
  const ts = opts.timestamp || Math.floor(Date.now() / 1000);
  const rawBody = Buffer.from(JSON.stringify(body));
  const sig = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`, 'utf8').digest('hex');
  const header = `t=${ts},v1=${sig}`;
  return { rawBody, signatureHeader: header };
}

function buildSessionEvent({ productId = 'pro-30d', sessionId, customerEmail = 'alice@example.com',
                              eventId, lineItems, paymentStatus = 'paid' }) {
  const product = catalog.getProduct(productId);
  // For tests of "unknown productId" the catalog.getProduct returns null —
  // we still build a valid event so the bridge can return its own 400.
  const priceId = product ? catalog.getConfiguredPrice(product) : 'price_unconfigured';
  return {
    id: eventId || `evt_${crypto.randomBytes(6).toString('hex')}`,
    type: 'checkout.session.completed',
    data: {
      object: {
        id: sessionId || `cs_test_${crypto.randomBytes(6).toString('hex')}`,
        customer_email: customerEmail,
        customer_details: { email: customerEmail },
        payment_status: paymentStatus,
        amount_total: product ? product.amountCents : 0,
        currency: 'usd',
        metadata: { productId, product: 'dashcaddy-pro' },
        line_items: { data: lineItems || [{ price: { id: priceId } }] },
      },
    },
  };
}

function injectSmtp(impl) {
  nodemailerMock.createTransport.mockImplementation(() => ({
    sendMail: jest.fn().mockImplementation(impl),
  }));
}

describe('stripe-license-bridge signature verification', () => {
  test('rejects missing signature header', async () => {
    const { rawBody } = buildSignedPayload({ id: 'evt_1', type: 'x' });
    const result = await bridge.handleWebhook({ rawBody, signatureHeader: '' });
    expect(result.status).toBe(400);
    expect(result.body.reason).toBe('signature-missing-signature');
  });

  test('rejects wrong signature', async () => {
    const event = buildSessionEvent({ productId: 'pro-30d' });
    const ts = Math.floor(Date.now() / 1000);
    const rawBody = Buffer.from(JSON.stringify(event));
    const sig = crypto.createHmac('sha256', 'wrong').update(`${ts}.${rawBody}`, 'utf8').digest('hex');
    const result = await bridge.handleWebhook({ rawBody, signatureHeader: `t=${ts},v1=${sig}` });
    expect(result.status).toBe(400);
    expect(result.body.reason).toMatch(/^signature-/);
  });

  test('rejects out-of-tolerance timestamp', async () => {
    const event = buildSessionEvent({ productId: 'pro-30d' });
    const oldTs = Math.floor(Date.now() / 1000) - 3600; // 1h ago, > 300s tolerance
    const { rawBody, signatureHeader } = buildSignedPayload(event, { timestamp: oldTs });
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(400);
    expect(result.body.reason).toBe('signature-timestamp-out-of-tolerance');
  });
});

describe('stripe-license-bridge event parsing', () => {
  test('rejects invalid JSON', async () => {
    const rawBody = Buffer.from('not json');
    const ts = Math.floor(Date.now() / 1000);
    const sig = crypto.createHmac('sha256', process.env.STRIPE_WEBHOOK_SECRET).update(`${ts}.${rawBody}`, 'utf8').digest('hex');
    const result = await bridge.handleWebhook({ rawBody, signatureHeader: `t=${ts},v1=${sig}` });
    expect(result.status).toBe(400);
    expect(result.body.reason).toBe('invalid-json');
  });

  test('rejects event without id', async () => {
    const event = { type: 'checkout.session.completed', data: { object: {} } };
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(400);
    expect(result.body.reason).toBe('invalid-event');
  });

  test('acks unknown event types with 200 (so Stripe stops retrying)', async () => {
    const event = { id: 'evt_unknown', type: 'customer.created', data: { object: {} } };
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(200);
    expect(result.body.reason).toBe('ignored-event-type');
  });
});

describe('stripe-license-bridge catalog resolution', () => {
  test('rejects session without productId metadata', async () => {
    const event = buildSessionEvent({ productId: 'pro-30d' });
    delete event.data.object.metadata.productId;
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(400);
    expect(result.body.reason).toBe('missing-productId');
  });

  test('rejects unknown productId', async () => {
    const event = buildSessionEvent({ productId: 'pro-1000d' });
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(400);
    expect(result.body.reason).toBe('unknown-productId');
  });

  test('rejects when product Stripe Price is unconfigured', async () => {
    const productId = 'pro-30d';
    const saved = process.env.STRIPE_PRICE_PRO_30D;
    delete process.env.STRIPE_PRICE_PRO_30D;
    try {
      const event = buildSessionEvent({ productId });
      const { rawBody, signatureHeader } = buildSignedPayload(event);
      const result = await bridge.handleWebhook({ rawBody, signatureHeader });
      expect(result.status).toBe(400);
      expect(result.body.reason).toBe('product-not-configured');
    } finally {
      process.env.STRIPE_PRICE_PRO_30D = saved;
    }
  });

  test('rejects when customer email is missing', async () => {
    const event = buildSessionEvent({ productId: 'pro-30d', customerEmail: '' });
    delete event.data.object.customer_email;
    delete event.data.object.customer_details.email;
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(400);
    expect(result.body.reason).toBe('missing-customer-email');
  });

  test('accepts sessions without expanded line_items (Stripe webhook default)', async () => {
    // DC-057 acceptance: Stripe does NOT expand line_items in webhooks by
    // default — the bridge must accept the canonical metadata.productId
    // even when line_items is absent. (Price verification, when added,
    // should be an optional belt-and-suspenders via a separate API call,
    // not a hard requirement.)
    const event = buildSessionEvent({ productId: 'pro-30d' });
    delete event.data.object.line_items;
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(200);
    expect(result.body.delivered).toBe(true);
    expect(result.body.productId).toBe('pro-30d');
    expect(result.body.durationDays).toBe(30);
  });

  test('rejects unpaid sessions (no license until payment clears)', async () => {
    // DC-057: a checkout.session.completed event with payment_status='unpaid'
    // arrives when the customer closes the browser mid-checkout or for
    // delayed-payment methods (ACH/SEPA) before they clear. The bridge
    // MUST ack 200 (so Stripe stops retrying) but MUST NOT generate a
    // license. The async_payment_succeeded event will fire later.
    const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: 'unpaid' });
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(200);
    expect(result.body.delivered).toBe(false);
    expect(result.body.reason).toBe('payment-not-unpaid');
    expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
  });

  test('rejects no_payment_required sessions (DashCaddy does not sell free products)', async () => {
    // 'no_payment_required' is a Stripe-internal edge case for free
    // sessions. DashCaddy has no $0 product, so reject explicitly.
    const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: 'no_payment_required' });
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(200);
    expect(result.body.delivered).toBe(false);
    expect(result.body.reason).toBe('payment-not-no_payment_required');
    expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
  });

  test('rejects sessions with missing payment_status', async () => {
    const event = buildSessionEvent({ productId: 'pro-30d', paymentStatus: '' });
    delete event.data.object.payment_status;
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(200);
    expect(result.body.delivered).toBe(false);
    expect(result.body.reason).toBe('payment-not-confirmed');
    expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
  });

  test('fulfills async_payment_succeeded events for delayed-payment methods', async () => {
    // ACH/SEPA: Stripe first sends checkout.session.completed (unpaid),
    // then async_payment_succeeded (paid) when the bank clears. The
    // bridge generates the license on the second event.
    const sessionId = `cs_test_ach_${crypto.randomBytes(4).toString('hex')}`;
    const event = {
      id: `evt_ach_${crypto.randomBytes(6).toString('hex')}`,
      type: 'checkout.session.async_payment_succeeded',
      data: {
        object: {
          id: sessionId,
          customer_email: 'alice@example.com',
          customer_details: { email: 'alice@example.com' },
          payment_status: 'paid',
          amount_total: 5000,
          currency: 'usd',
          metadata: { productId: 'pro-90d', product: 'dashcaddy-pro' },
        },
      },
    };
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(200);
    expect(result.body.delivered).toBe(true);
    expect(result.body.productId).toBe('pro-90d');
    expect(result.body.durationDays).toBe(90);
  });

  test('acks async_payment_failed events without generating a license', async () => {
    const event = {
      id: `evt_ach_fail_${crypto.randomBytes(6).toString('hex')}`,
      type: 'checkout.session.async_payment_failed',
      data: {
        object: {
          id: `cs_test_fail_${crypto.randomBytes(6).toString('hex')}`,
          payment_status: 'unpaid',
        },
      },
    };
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(200);
    expect(result.body.delivered).toBe(false);
    expect(result.body.reason).toBe('async-payment-failed');
    expect(licenseKeygenMock.__getGenerateCalls()).toBe(0);
  });
});

describe('stripe-license-bridge happy path', () => {
  test('generates + persists + delivers license (dev-console SMTP fallback)', async () => {
    const event = buildSessionEvent({ productId: 'pro-90d' });
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });

    expect(result.status).toBe(200);
    expect(result.body.delivered).toBe(true);
    expect(result.body.productId).toBe('pro-90d');
    expect(result.body.durationDays).toBe(90);
    expect(result.body.codeId).toBeTruthy();
    expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);

    // Fulfillment record exists.
    const record = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE })
      .readBySession(event.data.object.id);
    expect(record.status).toBe('delivered');
    expect(record.code).toBeTruthy();
    expect(record.codeId).toBe(result.body.codeId);
    expect(record.deliveredVia).toBe('dev-console');
  });
});

describe('stripe-license-bridge idempotency', () => {
  test('duplicate eventId (Stripe retry) returns 200 without regenerating', async () => {
    const event = buildSessionEvent({ productId: 'pro-30d' });
    const { rawBody, signatureHeader } = buildSignedPayload(event);

    const first = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(first.status).toBe(200);
    expect(first.body.delivered).toBe(true);

    const second = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(second.status).toBe(200);
    expect(second.body.deduplicated).toBe(true);
    // generateCodes called exactly once across both deliveries.
    expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
  });

  test('two events for the same session reuse the same license (layer-2 idempotency)', async () => {
    const sessionId = `cs_test_shared_${crypto.randomBytes(4).toString('hex')}`;
    const eventA = buildSessionEvent({ productId: 'pro-30d', sessionId });
    const eventB = buildSessionEvent({ productId: 'pro-30d', sessionId });

    const payloadA = buildSignedPayload(eventA);
    const payloadB = buildSignedPayload(eventB);

    const rA = await bridge.handleWebhook({ rawBody: payloadA.rawBody, signatureHeader: payloadA.signatureHeader });
    const rB = await bridge.handleWebhook({ rawBody: payloadB.rawBody, signatureHeader: payloadB.signatureHeader });

    expect(rA.status).toBe(200);
    expect(rA.body.delivered).toBe(true);
    // Second event hits layer-1 idempotency by eventId — different eventId,
    // so falls through to layer-2 by sessionId; sees existing delivered record.
    expect(rB.status).toBe(200);
    expect(rB.body.delivered).toBe(true);
    expect(rB.body.codeId).toBe(rA.body.codeId); // SAME license code
    expect(licenseKeygenMock.__getGenerateCalls()).toBe(1); // only one code generated
  });
});

describe('stripe-license-bridge SMTP failure recovery (DC-057 acceptance)', () => {
  beforeEach(() => {
    // Inject SMTP BEFORE each test so SMTP_HOST is set when deliverCode runs.
    injectSmtp(async () => { throw new Error('smtp-down'); });
    process.env.SMTP_HOST = 'smtp.example.com';
    process.env.SMTP_FROM = 'noreply@example.com';
  });

  test('SMTP failure persists license, returns 500, but customer can retrieve via lookup', async () => {
    const event = buildSessionEvent({ productId: 'pro-180d' });
    const { rawBody, signatureHeader } = buildSignedPayload(event);

    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(500);
    expect(result.body.reason).toBe('email-failed');

    // License IS persisted (the documented SMTP-failure recovery path).
    const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
    const record = store.readBySession(event.data.object.id);
    expect(record.code).toBeTruthy();
    expect(record.status).toBe('pending_email');
    expect(record.lastError).toMatch(/smtp-down/);

    // The lookup endpoint serves the persisted code ANYWAY.
    const lookup = bridge.lookupSession(event.data.object.id);
    expect(lookup.status).toBe('pending_email');
    expect(lookup.code).toBe(record.code);
    expect(lookup.durationDays).toBe(180);
    expect(lookup.productId).toBe('pro-180d');
  });

  test('Stripe retry after SMTP failure keeps retrying (customer recovers via lookup)', async () => {
    const event = buildSessionEvent({ productId: 'pro-365d' });
    const { rawBody, signatureHeader } = buildSignedPayload(event);

    const first = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(first.status).toBe(500);

    // Stripe retries with the SAME eventId. SMTP is still down → bridge
    // keeps retrying (returns 500) until either SMTP recovers or Stripe
    // gives up. The customer recovery path is via the lookup endpoint —
    // the license IS persisted in the fulfillment store regardless.
    const retry = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(retry.status).toBe(500);

    const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
    const record = store.readBySession(event.data.object.id);
    expect(record.code).toBeTruthy();
    expect(record.status).toBe('pending_email');

    // Lookup serves the persisted code.
    const lookup = bridge.lookupSession(event.data.object.id);
    expect(lookup.code).toBe(record.code);

    // Only one license generated across the retries (layer-2 idempotency).
    expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
  });

  test('SMTP recovers on a subsequent attempt (different eventId, same session) — still reuses the persisted code', async () => {
    let smtpCalls = 0;
    injectSmtp(async () => {
      smtpCalls++;
      if (smtpCalls === 1) throw new Error('smtp-temp-down');
      return { messageId: 'msg-ok' };
    });

    const sessionId = `cs_test_recover_${crypto.randomBytes(4).toString('hex')}`;
    const eventA = buildSessionEvent({ productId: 'pro-30d', sessionId });
    const eventB = buildSessionEvent({ productId: 'pro-30d', sessionId });

    const payloadA = buildSignedPayload(eventA);
    const payloadB = buildSignedPayload(eventB);

    const rA = await bridge.handleWebhook({ rawBody: payloadA.rawBody, signatureHeader: payloadA.signatureHeader });
    expect(rA.status).toBe(500); // first attempt: SMTP down
    expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);

    // Read the persisted code from the store (rA.body doesn't include it on
    // failure — by design, we don't leak license material in error responses).
    const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
    const persistedCode = store.readBySession(sessionId).code;
    expect(persistedCode).toBeTruthy();

    const rB = await bridge.handleWebhook({ rawBody: payloadB.rawBody, signatureHeader: payloadB.signatureHeader });
    expect(rB.status).toBe(200); // second event, same session: reuses persisted code, delivery succeeds
    expect(rB.body.delivered).toBe(true);
    // Same code reused, NOT a fresh generation.
    expect(rB.body.codeId).toBeTruthy();

    // The store's codeId matches rB.body.codeId (proves reuse, not regeneration).
    expect(rB.body.codeId).toBe(store.readBySession(sessionId).codeId);

    // No new license generated.
    expect(licenseKeygenMock.__getGenerateCalls()).toBe(1);
    expect(smtpCalls).toBe(2);
  });
});

describe('stripe-license-bridge lookupSession', () => {
  test('returns not_found for unknown sessionId', () => {
    expect(bridge.lookupSession('cs_unknown')).toEqual({ status: 'not_found' });
  });

  test('returns expired for record past TTL', async () => {
    const event = buildSessionEvent({ productId: 'pro-30d' });
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    await bridge.handleWebhook({ rawBody, signatureHeader });

    // Far-future "now" past the 24h TTL.
    const future = Date.now() + 25 * 60 * 60 * 1000;
    const lookup = bridge.lookupSession(event.data.object.id, { nowMs: future });
    expect(lookup.status).toBe('expired');
  });

  test('returns processing state for fresh claim without code', async () => {
    const store = createFulfillmentStore({ filePath: process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE });
    const sessionId = `cs_test_processing_${crypto.randomBytes(4).toString('hex')}`;
    await store.claim({ eventId: 'evt_pend', sessionId, productId: 'pro-30d', durationDays: 30, email: 'a@b.c' });
    const lookup = bridge.lookupSession(sessionId);
    expect(lookup.status).toBe('processing');
    expect(lookup.durationDays).toBe(30);
    expect(lookup.productId).toBe('pro-30d');
  });
});

describe('stripe-license-bridge constants', () => {
  test('LOOKUP_TTL_MS defaults to 24h', () => {
    expect(bridge.LOOKUP_TTL_MS).toBe(24 * 60 * 60 * 1000);
  });

  test('DELIVERY_LEASE_MS is exported', () => {
    expect(bridge.DELIVERY_LEASE_MS).toBeGreaterThan(0);
  });
});

describe('stripe-license-bridge invoice + email rendering (DC-058)', () => {
  // These tests verify the bridge actually invokes the invoice renderer
  // with the right inputs and that the SMTP send receives a multipart
  // body + a PDF attachment. Pairs with invoice.test.js (which tests the
  // rendering primitives in isolation).

  test('passes customerName, sessionId, and amount through to the renderer', async () => {
    const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
    injectSmtp(sendMailMock);
    process.env.SMTP_HOST = 'smtp.test';
    process.env.SMTP_FROM = 'billing@dashcaddy.test';

    const event = buildSessionEvent({
      productId: 'pro-90d',
      customerEmail: 'alice@example.com',
    });
    // Add customer_details.name + amount_total in line_items[0] (like real Stripe).
    event.data.object.customer_details.name = 'Alice Johnson';
    event.data.object.line_items = {
      data: [{ amount_total: 5000, price: { id: 'price_90d_test', unit_amount: 5000 }, currency: 'usd' }],
    };

    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(200);
    expect(result.body.delivered).toBe(true);
    expect(result.body.deliveredVia).toBe('smtp');

    // Verify the SMTP send was called with branded email + PDF attachment.
    expect(sendMailMock).toHaveBeenCalledTimes(1);
    const mailArgs = sendMailMock.mock.calls[0][0];
    expect(mailArgs.from).toBe('billing@dashcaddy.test');
    expect(mailArgs.to).toBe('alice@example.com');
    // Subject contains duration and "invoice".
    expect(mailArgs.subject).toContain('DashCaddy Pro');
    expect(mailArgs.subject).toContain('invoice');
    // HTML + text both present (multipart/alternative).
    expect(mailArgs.text).toBeDefined();
    expect(mailArgs.html).toBeDefined();
    expect(mailArgs.html).toContain('Hi Alice');  // first name from customer_details.name
    expect(mailArgs.html).toContain('INV-');       // invoice number
    expect(mailArgs.html).toContain('$50.00');     // 90d tier price
    // PDF attachment present.
    expect(Array.isArray(mailArgs.attachments)).toBe(true);
    expect(mailArgs.attachments).toHaveLength(1);
    expect(mailArgs.attachments[0].filename).toMatch(/^DashCaddy-Pro-Invoice-INV-.+\.pdf$/);
    expect(mailArgs.attachments[0].contentType).toBe('application/pdf');
    expect(mailArgs.attachments[0].encoding).toBe('base64');
    expect(mailArgs.attachments[0].content.length).toBeGreaterThan(1000);  // real PDF
    // PDF magic bytes.
    expect(mailArgs.attachments[0].content.slice(0, 4).toString('ascii')).toBe('%PDF');
  });

  test('falls back to catalog amount when line_items are missing', async () => {
    const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
    injectSmtp(sendMailMock);
    process.env.SMTP_HOST = 'smtp.test';
    process.env.SMTP_FROM = 'billing@dashcaddy.test';

    const event = buildSessionEvent({ productId: 'pro-365d' });
    // Strip line_items entirely (simulates a webhook without expansion).
    delete event.data.object.line_items;
    delete event.data.object.amount_total;
    // Strip customer_details.name to verify "Hi there," fallback.
    delete event.data.object.customer_details.name;

    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(200);

    const mailArgs = sendMailMock.mock.calls[0][0];
    // Falls back to catalog: pro-365d is $99.00.
    expect(mailArgs.html).toContain('$99.00');
    expect(mailArgs.html).toContain('Hi there,');
  });

  test('dev-console fallback logs invoice number + PDF size', async () => {
    const event = buildSessionEvent({ productId: 'pro-30d' });
    event.data.object.customer_details.name = 'Bob';
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(200);
    expect(result.body.deliveredVia).toBe('dev-console');
    // We can't easily assert on log output from here, but the status proves
    // the dev-console path was taken. The log line includes pdfBytes —
    // covered indirectly by invoice.test.js verifying the PDF size.
  });

  test('uses claim createdAt as issuedAt (not now) for stable retry semantics', async () => {
    const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
    injectSmtp(sendMailMock);
    process.env.SMTP_HOST = 'smtp.test';
    process.env.SMTP_FROM = 'billing@dashcaddy.test';

    const event = buildSessionEvent({ productId: 'pro-30d' });
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const result = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(result.status).toBe(200);

    const mailArgs = sendMailMock.mock.calls[0][0];
    // The "Issued" line must reflect the claim's createdAt (which is when
    // the customer paid), not the moment we sent the email.
    expect(mailArgs.html).toMatch(/Issued[\s\S]*?\d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC/);
  });

  test('gracefully degrades to text-only email when PDF render fails', async () => {
    const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
    injectSmtp(sendMailMock);
    process.env.SMTP_HOST = 'smtp.test';
    process.env.SMTP_FROM = 'billing@dashcaddy.test';

    // Force PDF render to throw by passing an invalid issuedAt — this
    // exercises the try/catch around renderInvoicePdf and verifies the
    // bridge still sends a text+HTML email without the attachment.
    // (PDFKit auto-escapes most non-ASCII; lone surrogates no longer
    // throw on this PDFKit version. Bad dates remain a real crash path.)
    const event = buildSessionEvent({ productId: 'pro-30d' });
    // Override issuedAt to an invalid date via the bridge's deliverCode arg.
    // The bridge forwards this from the invoice module, which we can stub
    // at module level for this test.
    const invoiceMod = require('../../src/billing/invoice');
    const originalRender = invoiceMod.renderInvoicePdf;
    invoiceMod.renderInvoicePdf = jest.fn().mockRejectedValue(new Error('simulated PDF render failure'));
    try {
      const { rawBody, signatureHeader } = buildSignedPayload(event);
      const result = await bridge.handleWebhook({ rawBody, signatureHeader });
      expect(result.status).toBe(200);
      expect(result.body.delivered).toBe(true);
      expect(sendMailMock).toHaveBeenCalledTimes(1);
      const mailArgs = sendMailMock.mock.calls[0][0];
      // No PDF attachment when render failed.
      expect(mailArgs.attachments).toBeUndefined();
      // Text + HTML still sent (keygen mock uses DC-TEST-... in this suite).
      expect(mailArgs.text).toMatch(/DC-(PRO|TEST)-/);
      expect(mailArgs.html).toContain('DashCaddy');
    } finally {
      invoiceMod.renderInvoicePdf = originalRender;
    }
  });

  test('replay (layer-1 event-id idempotency) does NOT re-render the invoice', async () => {
    const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
    injectSmtp(sendMailMock);
    process.env.SMTP_HOST = 'smtp.test';
    process.env.SMTP_FROM = 'billing@dashcaddy.test';

    const event = buildSessionEvent({ productId: 'pro-30d' });
    const { rawBody, signatureHeader } = buildSignedPayload(event);
    const sessionId = event.data.object.id;

    // First delivery — generates a new license + invoice.
    const first = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(first.body.delivered).toBe(true);
    expect(first.body.codeId).toBeDefined();
    const firstCodeId = first.body.codeId;
    expect(sendMailMock).toHaveBeenCalledTimes(1);

    // Second delivery of the SAME event — should be deduplicated by event id
    // at the layer-1 check (bridge.checkEventIdempotency). SMTP must NOT be
    // called again because Stripe retrying the same event ID should never
    // re-send the invoice.
    const second = await bridge.handleWebhook({ rawBody, signatureHeader });
    expect(second.body.delivered).toBe(true);
    expect(second.body.deduplicated).toBe(true);
    expect(sendMailMock).toHaveBeenCalledTimes(1);
  });

  test('layer-2 (different event, same session) does NOT re-send the invoice', async () => {
    // Stripe can send BOTH `checkout.session.completed` AND
    // `checkout.session.async_payment_succeeded` for the same Checkout Session
    // (delayed-payment methods). Layer-1 dedup doesn't catch this because
    // the event IDs differ — only the session ID is the same. The bridge
    // MUST recognize that delivery already happened via the OTHER event and
    // ack 200 without re-sending.
    const sendMailMock = jest.fn().mockResolvedValue({ messageId: 'test' });
    injectSmtp(sendMailMock);
    process.env.SMTP_HOST = 'smtp.test';
    process.env.SMTP_FROM = 'billing@dashcaddy.test';

    const sessionId = `cs_test_layer2_${crypto.randomBytes(4).toString('hex')}`;
    const eventA = buildSessionEvent({
      productId: 'pro-30d',
      sessionId,
      eventId: `evt_A_${crypto.randomBytes(4).toString('hex')}`,
    });
    eventA.type = 'checkout.session.completed';

    const eventB = buildSessionEvent({
      productId: 'pro-30d',
      sessionId,
      eventId: `evt_B_${crypto.randomBytes(4).toString('hex')}`,
    });
    eventB.type = 'checkout.session.async_payment_succeeded';

    // First event: completes the payment, sends the invoice.
    const sigA = buildSignedPayload(eventA);
    const resultA = await bridge.handleWebhook({ rawBody: sigA.rawBody, signatureHeader: sigA.signatureHeader });
    expect(resultA.status).toBe(200);
    expect(resultA.body.delivered).toBe(true);
    expect(resultA.body.deduplicated).toBeUndefined();
    expect(sendMailMock).toHaveBeenCalledTimes(1);
    const firstInvoice = sendMailMock.mock.calls[0][0].attachments[0].filename;

    // Second event for the SAME session: must NOT re-send (different event
    // id, so layer-1 dedup doesn't catch it; layer-2 must).
    const sigB = buildSignedPayload(eventB);
    const resultB = await bridge.handleWebhook({ rawBody: sigB.rawBody, signatureHeader: sigB.signatureHeader });
    expect(resultB.status).toBe(200);
    expect(resultB.body.delivered).toBe(true);
    expect(resultB.body.deduplicated).toBe(true);
    // CRITICAL: only ONE SMTP call. Two invoice emails with different invoice
    // numbers for one charge is a financial-document bug.
    expect(sendMailMock).toHaveBeenCalledTimes(1);
    const secondInvoice = sendMailMock.mock.calls[0][0].attachments[0].filename;
    expect(secondInvoice).toBe(firstInvoice);  // same invoice number
  });
});
