#!/usr/bin/env node
/**
 * DashCaddy Stripe license bridge — DC-054 + DC-057.
 *
 * Tiny HTTP webhook listener that converts a Stripe Checkout completion into
 * a DashCaddy Pro license code + a confirmation email. Runs as its own
 * process (NOT inside the DashCaddy API) so the merchant's Stripe secret
 * material stays out of the host-side process tree.
 *
 * # Wire format
 *
 *   POST /webhook
 *   Stripe-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>
 *   <raw JSON body>
 *
 * The body for `checkout.session.completed` carries:
 *   { id, customer_email, metadata: { productId }, amount_total, currency, ... }
 *
 * # Catalog contract (DC-057)
 *
 *   `metadata.productId` is one of the IDs in src/billing/catalog.js:
 *     pro-30d | pro-90d | pro-180d | pro-365d
 *   The bridge maps productId → duration via the catalog (single source of
 *   truth shared with the Checkout client + pricing page). The catalog's
 *   configured Stripe Price is read via `STRIPE_PRICE_PRO_*D` env vars
 *   (already documented in the catalog) and is used to validate that the
 *   product is purchasable (configuredPriceId is not empty) — NOT to
 *   cross-validate the price against the customer's Stripe session. Price
 *   verification is intentionally omitted because (a) Stripe webhooks do
 *   not include expanded line_items by default and (b) trusting the price
 *   would block legitimate customers during a price rollover.
 *
 *   The previous `STRIPE_SKU_*` env vars are REMOVED in DC-057 — operators
 *   who set them should migrate to `STRIPE_PRICE_PRO_*D`.
 *
 * # Generation flow
 *
 *   1. Verify Stripe-Signature (constant-time HMAC-SHA256 compare).
 *      Reject with 400 if the timestamp is more than TOLERANCE_SECONDS
 *      old, or if any v1 signature is missing.
 *   2. Look up the event in the per-event idempotency file
 *      (data/stripe-events.json). If seen, replay the previous response
 *      status (200) WITHOUT regenerating. This is the layer-1 idempotency.
 *   3. If the event type isn't `checkout.session.completed`, ack with
 *      `{delivered: false, reason: "ignored-event-type"}` so Stripe stops
 *      retrying, and record the event.
 *   4. Parse the session metadata; resolve productId via the catalog.
 *   5. CLAIM the fulfillment record (atomic, sessionId-keyed).
 *      - If the record exists for this sessionId in `pending_email` or
 *        `delivered` state (e.g. a previous webhook delivery succeeded OR
 *        saved a license but email failed), we REUSE the persisted license
 *        code — never generate a second one. This is the layer-2
 *        idempotency keyed by Checkout Session ID (globally unique, never
 *        reused even when the same event is replayed).
 *      - If the record exists in `generating` state and the lease is held
 *        by a DIFFERENT event (concurrent webhook fan-out), respond 409
 *        so Stripe retries — only one delivery wins.
 *   6. SAVE the license code into the fulfillment record
 *      (data/stripe-fulfillments.json) BEFORE attempting email. This is
 *      the crash-safety guarantee: even if email fails AND the process
 *      is killed, the license is durably persisted.
 *   7. DELIVER the code via SMTP (or dev-console fallback).
 *      - On success: markDelivered. Lookup endpoint serves the code on
 *        the success page.
 *      - On failure: markDeliveryFailed → reverts to pending_email. Lookup
 *        endpoint serves the code ANYWAY (with a "(email delivery didn't
 *        complete)" notice) — customer can save it manually. Subsequent
 *        webhook retries reuse the same persisted code via step 5.
 *   8. Respond 500 to Stripe ONLY if the license save succeeded but email
 *      failed AND no successful delivery record exists — Stripe will
 *      retry. If delivery already succeeded earlier, ack 200.
 *
 * # SMTP transport
 *
 *   Reuses the same env vars as DashCaddy's notification system:
 *     SMTP_HOST / SMTP_PORT / SMTP_USERNAME / SMTP_PASSWORD / SMTP_FROM / SMTP_SECURE
 *   If HOST or FROM is missing, delivery falls back to dev-console mode
 *   (the bridge logs the full email body so the operator can deliver it
 *   manually). This is the documented dev path; do NOT enable it in
 *   production.
 *
 * # Exit codes
 *
 *   0 — clean shutdown
 *   1 — fatal startup error (missing secret, port bind failure, no
 *       products configured)
 *   2 — runtime error while handling a request (logged, 500 returned)
 *
 * # Security notes
 *
 *   - Webhook signature MUST verify BEFORE any JSON parsing. The raw body
 *     is opaque until HMAC checks out.
 *   - Timing-safe signature comparison (crypto.timingSafeEqual).
 *   - The fulfillment-store file lives in platformPaths.dataDir (bind-
 *     mounted in production). Atomic writes + per-mutation mutex.
 *
 * Tested in __tests__/billing/stripe-license-bridge.test.js (no live network).
 */

'use strict';

const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { generateCodes, loadSecret } = require('../license-keygen');
const platformPaths = require('../platform-paths');
const catalog = require('../src/billing/catalog');
const invoice = require('../src/billing/invoice');
const { createFulfillmentStore, DELIVERY_LEASE_MS } = require('../src/billing/fulfillment-store');

// ── Configuration (env-driven) ──────────────────────────────────────────────

const PORT = parseInt(process.env.STRIPE_BRIDGE_PORT || '3010', 10);
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
const TOLERANCE_SECONDS = parseInt(process.env.STRIPE_BRIDGE_TOLERANCE || '300', 10);

// SMTP. Falls back to dev-console mode if HOST or FROM is missing.
// Read at function-call time (not module load) so tests can toggle SMTP
// behavior between cases without re-requiring the bridge.
function _smtpConfig() {
  return {
    host: process.env.SMTP_HOST || '',
    port: parseInt(process.env.SMTP_PORT || '587', 10),
    secure: process.env.SMTP_SECURE === 'true',
    username: process.env.SMTP_USERNAME || '',
    password: process.env.SMTP_PASSWORD || '',
    from: process.env.SMTP_FROM || '',
  };
}

// State files (atomic write). Override paths in tests.
const STATE_DIR = process.env.STRIPE_BRIDGE_STATE_DIR || platformPaths.dataDir;
const EVENTS_FILE = process.env.STRIPE_BRIDGE_EVENTS_FILE || path.join(STATE_DIR, 'stripe-events.json');
const FULFILLMENT_STORE = process.env.STRIPE_BRIDGE_FULFILLMENT_STORE_FILE
  || path.join(STATE_DIR, 'stripe-fulfillments.json');

// Lookup TTL: after a license has been "delivered" for this long, the
// /api/v1/billing/lookup/:sessionId endpoint returns 404 even with a valid
// sessionId. 24 hours matches Stripe's default Checkout session expiry and
// is far longer than any customer needs to paste their key.
const LOOKUP_TTL_MS = parseInt(process.env.STRIPE_BRIDGE_LOOKUP_TTL_MS || String(24 * 60 * 60 * 1000), 10);

// Build the fulfillment store singleton used by both bridge writes and
// lookup reads (the API's lookup endpoint reads from the SAME file via
// its own createFulfillmentStore() instance — file is the IPC channel).
const fulfillmentStore = createFulfillmentStore({ filePath: FULFILLMENT_STORE });

// ── Helpers ────────────────────────────────────────────────────────────────

function log(level, msg, meta) {
  const line = JSON.stringify({ ts: new Date().toISOString(), level, msg, ...(meta || {}) });
  process.stdout.write(line + '\n');
}

/**
 * Verify a Stripe-Signature header. Returns { ok: true } if the signature is
 * well-formed, within tolerance, AND matches at least one v1 entry.
 * Returns { ok: false, reason } otherwise — callers MUST 400 on failure.
 *
 * Format: t=<unix_ts>,v1=<hex>[,v1=<hex>]*
 *
 * The signed payload is `${t}.${rawBody}`. We recompute HMAC-SHA256 of that
 * exact byte sequence with the webhook secret, then timing-safe-compare
 * against each v1 entry until one matches. Multiple v1 entries are allowed
 * during Stripe secret rotation; we just need one to verify.
 */
function verifyStripeSignature(rawBody, header, secret, nowSec) {
  if (!header || typeof header !== 'string') return { ok: false, reason: 'missing-signature' };
  if (!secret) return { ok: false, reason: 'no-server-secret' };

  const parts = header.split(',').map((s) => s.trim()).filter(Boolean);
  let timestamp = null;
  const v1List = [];
  for (const part of parts) {
    const eq = part.indexOf('=');
    if (eq < 0) continue;
    const key = part.slice(0, eq);
    const val = part.slice(eq + 1);
    if (key === 't') timestamp = parseInt(val, 10);
    else if (key === 'v1') v1List.push(val);
  }
  if (!Number.isFinite(timestamp)) return { ok: false, reason: 'missing-timestamp' };
  if (v1List.length === 0) return { ok: false, reason: 'missing-v1' };

  const skew = Math.abs((nowSec || Math.floor(Date.now() / 1000)) - timestamp);
  if (skew > TOLERANCE_SECONDS) return { ok: false, reason: 'timestamp-out-of-tolerance' };

  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest();
  for (const v1 of v1List) {
    let got;
    try {
      got = Buffer.from(v1, 'hex');
    } catch (_) {
      continue;
    }
    if (got.length !== expected.length) continue;
    if (crypto.timingSafeEqual(got, expected)) return { ok: true };
  }
  return { ok: false, reason: 'no-matching-signature' };
}

// ── Idempotency store (event-id-keyed layer 1) ─────────────────────────────

function readEvents() {
  try {
    const raw = fs.readFileSync(EVENTS_FILE, 'utf8');
    const parsed = JSON.parse(raw);
    if (parsed && typeof parsed === 'object' && parsed.events && typeof parsed.events === 'object') {
      return parsed;
    }
    return { events: {} };
  } catch (err) {
    if (err && err.code === 'ENOENT') return { events: {} };
    // Treat any parse error as an empty store — the next successful write
    // will replace the file. Worst case we re-deliver; Stripe tolerates
    // duplicate emails.
    return { events: {} };
  }
}

function writeEvents(state) {
  // Atomic write: tmp + rename.
  const tmp = `${EVENTS_FILE}.tmp.${process.pid}.${Date.now()}`;
  fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
  fs.renameSync(tmp, EVENTS_FILE);
}

function recordEvent(eventId, meta) {
  const state = readEvents();
  if (state.events[eventId]) return false; // already delivered
  state.events[eventId] = {
    receivedAt: new Date().toISOString(),
    ...(meta || {}),
  };
  writeEvents(state);
  return true;
}

function eventSeen(eventId) {
  const state = readEvents();
  return Boolean(state.events[eventId]);
}

// ── Email delivery ─────────────────────────────────────────────────────────

/**
 * Send the license key + invoice email. If SMTP is configured, real send via
 * nodemailer; if not, log the full email body to stdout so the operator
 * can deliver manually in dev/test environments.
 *
 * The email is multipart/alternative (text + HTML, matching the same
 * branded content) with a branded PDF invoice attached. Rendered by
 * src/billing/invoice.js — see that module for the security/escape rules.
 *
 * Returns { delivered: bool, via: 'smtp' | 'dev-console' }.
 */
async function deliverCode({ to, code, durationDays, eventId, productId, customerName, sessionId, amountCents, currency, supportUrl, issuedAt }) {
  const product = catalog.getProduct(productId);
  if (!product) {
    // Should never happen — catalog resolution happens upstream. Defensive
    // throw so the operator notices misconfiguration instead of silently
    // sending a half-blank invoice.
    throw new Error(`deliverCode: unknown productId ${productId}`);
  }

  const invoiceInput = {
    email: to,
    customerName: customerName || '',
    code,
    durationDays,
    productLabel: product.label,
    productId: product.id,
    amountCents: amountCents != null ? amountCents : product.amountCents,
    currency: currency || 'USD',
    eventId,
    sessionId: sessionId || '',
    supportUrl: supportUrl || 'https://dashcaddy.net',
    issuedAt: issuedAt || new Date().toISOString(),
  };

  const { subject, html } = invoice.renderLicenseEmailHtml(invoiceInput);
  const text = invoice.renderLicenseEmailText(invoiceInput);

  // PDF generation can throw on poison-pill inputs that survive sanitization
  // (e.g. lone surrogates in customer name that PDFKit's WinAnsi encoder
  // rejects, or malformed `issuedAt` after the bridge passes a bad value).
  // We try the PDF and degrade gracefully: send text+HTML WITHOUT the PDF
  // attachment so the customer still gets the license + invoice link rather
  // than nothing. The fulfillment record still marks `delivered` — the
  // license was persisted upstream, so lookup always works regardless.
  let pdfBuffer = null;
  let pdfError = null;
  try {
    pdfBuffer = await invoice.renderInvoicePdf(invoiceInput);
  } catch (err) {
    pdfError = err;
    log('warn', 'pdf-render-failed-degrading-to-text-only', {
      eventId, sessionId, error: err.message,
    });
  }

  // Sanitize the PDF filename — event id has Stripe's prefix and underscores
  // which are safe, but we constrain the charset anyway for attachment
  // parsers that may be picky.
  const safeInvoiceNumber = invoice.sanitizeFilenameSegment(
    invoice.generateInvoiceNumber(eventId),
    'invoice'
  );
  const attachmentFilename = `DashCaddy-Pro-Invoice-${safeInvoiceNumber}.pdf`;

  const smtp = _smtpConfig();
  if (!smtp.host || !smtp.from) {
    // Dev-console fallback: log the full email body to stdout so the
    // operator can deliver manually in dev/test environments. The
    // fulfillment record is marked `delivered` with `via: 'dev-console'`
    // so the lookup endpoint serves the code on the success page — the
    // operator seeing the bridge logs IS the documented delivery path
    // when SMTP is unconfigured. In production, the bridge refuses to
    // boot without SMTP configured (see checkFatalConfig).
    log('info', 'smtp-not-configured, falling back to dev-console delivery', {
      to, durationDays, code, invoiceNumber: safeInvoiceNumber,
      pdfBytes: pdfBuffer ? pdfBuffer.length : null, pdfError: pdfError && pdfError.message,
    });
    return { delivered: true, via: 'dev-console' };
  }

  // Lazy-load nodemailer so the test suite doesn't pull it into coverage.
  const nodemailer = require('nodemailer');
  const transporter = nodemailer.createTransport({
    host: smtp.host,
    port: smtp.port,
    secure: smtp.secure,
    auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
    tls: { rejectUnauthorized: process.env.NODE_ENV === 'production' },
  });
  const mailArgs = {
    from: smtp.from,
    to,
    subject,
    text,
    html,
  };
  if (pdfBuffer) {
    mailArgs.attachments = [
      {
        filename: attachmentFilename,
        content: pdfBuffer,
        contentType: 'application/pdf',
        encoding: 'base64',
      },
    ];
  }
  await transporter.sendMail(mailArgs);
  return { delivered: true, via: 'smtp' };
}

// ── Request handler ────────────────────────────────────────────────────────

/**
 * Resolve a Stripe session to a catalog product + duration.
 *
 * The source of truth is `metadata.productId` — which the stripe-client
 * sets when it creates the Checkout Session (see stripe-client.js).
 * The customer is identified by the product they intended to buy, NOT
 * by the Stripe Price ID at fulfillment time, because:
 *
 *   - A repoint of STRIPE_PRICE_PRO_30D to a new Stripe Price affects
 *     NEW Checkout Sessions only. Existing sessions retain their
 *     original line_items.price.id; their metadata.productId is
 *     unchanged. Trusting price would force the operator to keep the
 *     old Price ID configured indefinitely (or forever block customers
 *     who started checkout before the rollover).
 *   - Stripe does not include expanded line_items in webhook payloads
 *     by default. To get them we'd need either a separate
 *     stripe.checkout.sessions.retrieve() call per webhook or Stripe's
 *     webhook-expansion feature. Neither is worth the cost when the
 *     metadata is already a complete canonical identifier.
 *
 * Returns { product, durationDays } on success or { error, ...details } on failure.
 */
function resolveProductFromSession(session) {
  if (!session || typeof session !== 'object') return null;
  const productId = session.metadata && session.metadata.productId;
  if (!productId) return { error: 'missing-productId' };

  const product = catalog.getProduct(productId);
  if (!product) return { error: 'unknown-productId', productId };

  const configuredPriceId = catalog.getConfiguredPrice(product);
  if (!configuredPriceId) return { error: 'product-not-configured', productId, missing: product.priceEnv };

  return { product, durationDays: product.durationDays };
}

/**
 * Process one webhook delivery. Pure-ish: takes the raw body, signature
 * header, and event id; returns an HTTP-friendly result object.
 *
 * Exported for tests. The HTTP wrapper below calls this with the parsed
 * inputs and turns the result into a response.
 *
 * Composed of small step functions to keep individual cyclomatic complexity
 * under ESLint's limit of 20.
 */
async function handleWebhook({ rawBody, signatureHeader, eventId }) {
  const sigResult = verifySignature(rawBody, signatureHeader);
  if (sigResult) return sigResult;

  const event = parseEventBody(rawBody);
  if (event.error) return event.error;

  const id = eventId || event.body.id;

  const dup = checkEventIdempotency(id);
  if (dup) return dup;

  // Only handle the FULFILLMENT_EVENT_TYPES below. Everything else falls
  // into the ACK_ONLY_EVENT_TYPES or the catch-all ignored branch.
  //
  //   - `checkout.session.completed` — fires for ALL completed sessions
  //     (paid OR unpaid). We only fulfill when payment_status='paid'.
  //   - `checkout.session.async_payment_succeeded` — fires for delayed
  //     payment methods (ACH/SEPA/bank debits) when the bank clears.
  //     Stripe sends `checkout.session.completed` first (unpaid), then
  //     this event when the payment confirms. payment_status is always
  //     'paid' on this event.
  //   - `checkout.session.async_payment_failed` — ack-only; the customer
  //     must retry from the pricing page.
  const FULFILLMENT_EVENT_TYPES = new Set([
    'checkout.session.completed',
    'checkout.session.async_payment_succeeded',
  ]);
  const ACK_ONLY_EVENT_TYPES = new Set([
    'checkout.session.async_payment_failed',
  ]);

  if (!FULFILLMENT_EVENT_TYPES.has(event.body.type)) {
    if (ACK_ONLY_EVENT_TYPES.has(event.body.type)) {
      // Permanent failure for delayed payments. Ack 200 so Stripe
      // stops retrying; the customer must retry from the pricing page.
      recordEvent(id, { ignoredType: event.body.type });
      return { status: 200, body: { delivered: false, reason: 'async-payment-failed' } };
    }
    recordEvent(id, { ignoredType: event.body.type });
    return { status: 200, body: { delivered: false, reason: 'ignored-event-type' } };
  }

  const session = event.body.data && event.body.data.object;
  if (!session || typeof session !== 'object') {
    return { status: 400, body: { delivered: false, reason: 'missing-session' } };
  }

  // Guard: only fulfill PAID sessions. Stripe sends
  // `checkout.session.completed` for BOTH paid AND unpaid events (e.g.
  // when the customer closes the browser mid-checkout). The `payment_status`
  // field on the session object disambiguates:
  //   - 'paid' — payment succeeded; we generate the license.
  //   - 'unpaid' — delayed-payment method (ACH/SEPA) not yet cleared;
  //     the async_payment_succeeded event will fire later and we
  //     generate the license then. Ack 200 here so Stripe stops
  //     retrying (the async event will be the fulfillment trigger).
  //   - 'no_payment_required' — Stripe-internal edge case for free
  //     sessions. DashCaddy doesn't sell any, so we reject.
  //   - absent — Stripe sometimes omits it on incomplete sessions;
  //     reject to be safe.
  const paymentStatus = session.payment_status;
  if (paymentStatus !== 'paid') {
    recordEvent(id, { ignoredType: event.body.type, paymentStatus });
    return { status: 200, body: { delivered: false, reason: `payment-not-${paymentStatus || 'confirmed'}` } };
  }

  return await fulfillCheckout({ id, session });
}

function verifySignature(rawBody, signatureHeader) {
  const nowSec = Math.floor(Date.now() / 1000);
  const sigCheck = verifyStripeSignature(rawBody, signatureHeader, WEBHOOK_SECRET, nowSec);
  if (!sigCheck.ok) {
    return { status: 400, body: { delivered: false, reason: `signature-${sigCheck.reason}` } };
  }
  return null;
}

function parseEventBody(rawBody) {
  let event;
  try {
    event = JSON.parse(rawBody.toString('utf8'));
  } catch (_) {
    return { error: { status: 400, body: { delivered: false, reason: 'invalid-json' } } };
  }
  if (!event || typeof event !== 'object' || !event.id) {
    return { error: { status: 400, body: { delivered: false, reason: 'invalid-event' } } };
  }
  return { body: event };
}

function checkEventIdempotency(id) {
  if (eventSeen(id)) {
    return { status: 200, body: { delivered: true, deduplicated: true } };
  }
  return null;
}

/**
 * Run the catalog → claim → deliver pipeline for one checkout session.
 * Returns the final HTTP-friendly result.
 */
async function fulfillCheckout({ id, session }) {
  const resolution = resolveProductFromSession(session);
  if (!resolution || resolution.error) {
    const reason = resolution && resolution.error ? resolution.error : 'missing-productId';
    log('warn', 'catalog-resolution-failed', { eventId: id, reason, ...(resolution || {}) });
    return { status: 400, body: { delivered: false, reason } };
  }
  const { product, durationDays } = resolution;

  const email = session.customer_email || (session.customer_details && session.customer_details.email) || '';
  if (!email) return { status: 400, body: { delivered: false, reason: 'missing-customer-email' } };

  const sessionId = session.id || '';
  if (!sessionId) return { status: 400, body: { delivered: false, reason: 'missing-session-id' } };

  // Stripe sends the customer's name on `customer_details.name` for hosted
  // Checkout (sometimes blank — they may have entered only an email). We
  // pass it through to the invoice renderer for the "Hi <first name>" greeting
  // and the bill-to block.
  const customerName = (session.customer_details && session.customer_details.name) || '';

  // Amount comes from the session's line_items (Stripe Checkout totals).
  // Older sessions may not have line_items expanded — fall back to the
  // session amount_total, then to the catalog amount so the invoice is
  // never blank. The invoice is a financial document — we ALWAYS render
  // the catalog's canonical amount when Stripe doesn't tell us a different
  // one, because the catalog is the single source of truth for DashCaddy's
  // pricing. This prevents Stripe Checkout config drift (e.g. a test
  // coupon, a multi-seat plan we don't support) from producing invoices
  // that don't match the user's actual entitlement.
  let amountCents = null;
  let currency = (session.currency || 'USD').toString().toUpperCase();
  const lineItems = session.line_items && session.line_items.data;
  if (Array.isArray(lineItems) && lineItems.length > 0) {
    // Sum ALL line items, not just lineItems[0]. The previous version
    // silently dropped quantity > 1 or multi-item carts, producing
    // invoices whose total didn't match the Stripe charge. session.amount_total
    // does this automatically too, but reading line items ourselves lets us
    // log a warning when Stripe's amount_total disagrees with the line-item
    // sum (indicative of a Stripe-side bug or tampering).
    const sumFromLineItems = lineItems.reduce((acc, item) => {
      if (item && item.amount_total != null) return acc + item.amount_total;
      if (item && item.price && item.price.unit_amount != null) return acc + item.price.unit_amount;
      return acc;
    }, 0);
    if (sumFromLineItems > 0) amountCents = sumFromLineItems;
    if (lineItems[0] && lineItems[0].currency) currency = lineItems[0].currency.toUpperCase();
  }
  if (amountCents == null && session.amount_total != null) {
    amountCents = session.amount_total;
  }
  // Final fallback: catalog's canonical price for this product. This is
  // the single source of truth — if Stripe sends 0 or NaN, we render the
  // catalog price rather than a $0.00 invoice for a real charge.
  if (amountCents == null || !Number.isFinite(amountCents) || amountCents <= 0) {
    log('warn', 'amount-fell-back-to-catalog', {
      eventId: id, sessionId, stripeAmountCents: amountCents, catalogAmountCents: product.amountCents,
    });
    amountCents = product.amountCents;
  }
  // Currency must always be a 3-letter ISO code; sanitize otherwise.
  if (!/^[A-Z]{3}$/.test(currency)) {
    log('warn', 'invalid-currency-from-stripe', { eventId: id, sessionId, stripeCurrency: currency });
    currency = 'USD';
  }

  const claim = await fulfillmentStore.claim({
    eventId: id, sessionId, productId: product.id, durationDays, email,
  });
  if (claim.busy) {
    return { status: 409, body: { delivered: false, reason: 'concurrent-delivery', retryable: true } };
  }

  const licenseResult = await ensureLicensePersisted({ id, sessionId, product, claim, durationDays });
  if (licenseResult.error) return licenseResult.error;
  const { code, codeId } = licenseResult;

  const deliveryClaim = await fulfillmentStore.claimDelivery({ sessionId, ownerToken: id });
  if (deliveryClaim.busy) {
    return { status: 409, body: { delivered: false, reason: 'concurrent-delivery', retryable: true } };
  }
  // Layer-2 delivery idempotency: if the claim was NOT successful AND the
  // record is already `delivered`, an earlier event (or this same event via
  // layer-1) already produced an invoice email. Stripe may legitimately send
  // `checkout.session.completed` AND `checkout.session.async_payment_succeeded`
  // for the same Checkout Session (delayed-payment methods). Without this
  // guard the customer receives TWO invoice emails with TWO different
  // invoice numbers for one charge. Ack 200 so Stripe stops retrying.
  if (deliveryClaim.claimed === false
      && deliveryClaim.record
      && deliveryClaim.record.status === 'delivered') {
    log('info', 'delivery-already-completed', {
      eventId: id, sessionId, ownerEventId: deliveryClaim.record.eventId,
    });
    return {
      status: 200,
      body: {
        delivered: true,
        deduplicated: true,
        codeId: deliveryClaim.record.codeId,
        productId: deliveryClaim.record.productId,
        durationDays: deliveryClaim.record.durationDays,
        deliveredVia: deliveryClaim.record.deliveredVia || 'smtp',
      },
    };
  }

  let delivery;
  try {
    delivery = await deliverCode({
      to: email,
      code,
      durationDays,
      eventId: id,
      productId: product.id,
      customerName,
      sessionId,
      amountCents,
      currency,
      // Pin issuedAt to the ORIGINAL claim's createdAt so a retry days later
      // renders the same "Issued" date. Falls back to now() for first-time.
      issuedAt: claim.record && claim.record.createdAt,
      supportUrl: process.env.DASHCADDY_SUPPORT_URL || 'https://dashcaddy.net',
    });
  } catch (err) {
    log('error', 'email-delivery-failed', { eventId: id, sessionId, error: err.message });
    await fulfillmentStore.markDeliveryFailed({ sessionId, ownerToken: id, error: err.message });
    return { status: 500, body: { delivered: false, reason: 'email-failed', error: err.message } };
  }

  await fulfillmentStore.markDelivered({ sessionId, ownerToken: id, deliveredVia: delivery.via });
  recordEvent(id, { durationDays, codeId, productId: product.id, email, deliveredVia: delivery.via });

  return {
    status: 200,
    body: { delivered: true, codeId, productId: product.id, durationDays, deliveredVia: delivery.via },
  };
}

/**
 * Either reuse an existing persisted code (layer-2 idempotency) or
 * generate + persist a fresh one. Returns { code, codeId } or
 * { error: <http-result> }.
 */
async function ensureLicensePersisted({ id, sessionId, product, claim, durationDays }) {
  const existing = claim.record;
  if (existing.code) {
    // Reusing an existing license (from a previous successful or failed
    // attempt for the SAME session). This is the retry-safe path.
    log('info', 'license-reused-from-fulfillment-store', {
      eventId: id, sessionId, productId: product.id, status: existing.status,
    });
    return { code: existing.code, codeId: existing.codeId };
  }

  let secret;
  try {
    secret = loadSecret();
  } catch (err) {
    log('error', 'license-secret-missing', { error: err.message });
    return { error: { status: 500, body: { delivered: false, reason: 'server-not-configured' } } };
  }

  const codes = generateCodes({ secret, durationDays, count: 1 });
  const code = codes[0].code;
  const codeId = codes[0].codeId;

  // Persist BEFORE attempting email. From this point on, the license is
  // durable even if the process dies or email fails.
  const saveResult = await fulfillmentStore.saveLicense({ eventId: id, sessionId, code, codeId });
  if (!saveResult.saved) {
    log('error', 'license-save-rejected', { eventId: id, sessionId, record: saveResult.record });
    return { error: { status: 500, body: { delivered: false, reason: 'save-rejected' } } };
  }
  return { code, codeId };
}

/**
 * Read a fulfillment record for the public lookup endpoint.
 *
 * Returns:
 *   { status: 'not_found' } — no record exists (session not paid / unknown)
 *   { status: 'expired' } — record exists but is past the lookup TTL
 *   { status: 'processing', durationDays } — license being generated
 *   { status: 'pending_email', durationDays, code, codeId, deliveredVia? } — license persisted, email failed
 *   { status: 'delivered', durationDays, code, codeId, deliveredVia } — license delivered
 *
 * The lookup endpoint serves the persisted code in BOTH pending_email AND
 * delivered states — that is the documented SMTP-failure recovery path
 * (the customer pastes their key even if email failed).
 */
function lookupSession(sessionId, { nowMs = Date.now() } = {}) {
  const record = fulfillmentStore.readBySession(sessionId);
  if (!record) return { status: 'not_found' };

  const createdAt = record.createdAt ? Date.parse(record.createdAt) : nowMs;
  const ageMs = nowMs - createdAt;
  if (Number.isFinite(ageMs) && ageMs > LOOKUP_TTL_MS) {
    return { status: 'expired' };
  }

  if (record.status === 'generating') {
    return { status: 'processing', durationDays: record.durationDays, productId: record.productId };
  }
  if (!record.code) {
    // Should not happen after saveLicense() succeeds, but defensive.
    return { status: 'processing', durationDays: record.durationDays, productId: record.productId };
  }
  const base = {
    durationDays: record.durationDays,
    code: record.code,
    codeId: record.codeId,
    productId: record.productId,
  };
  if (record.status === 'delivered') {
    return { status: 'delivered', deliveredVia: record.deliveredVia || 'unknown', ...base };
  }
  // pending_email OR delivering — license is durably persisted.
  return { status: 'pending_email', deliveredVia: record.deliveredVia, lastError: record.lastError, ...base };
}

// ── HTTP server ────────────────────────────────────────────────────────────

const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MB — Stripe events are small.

function readRawBody(req) {
  return new Promise((resolve, reject) => {
    let size = 0;
    const chunks = [];
    req.on('data', (chunk) => {
      size += chunk.length;
      if (size > MAX_BODY_BYTES) {
        reject(new Error('body-too-large'));
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });
    req.on('end', () => resolve(Buffer.concat(chunks)));
    req.on('error', reject);
  });
}

function writeJson(res, status, body, extraHeaders = {}) {
  const text = JSON.stringify(body);
  res.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8',
    'Content-Length': Buffer.byteLength(text),
    // License codes are bearer-style secrets — never cache them.
    'Cache-Control': 'no-store',
    ...extraHeaders,
  });
  res.end(text);
}

/**
 * Create an HTTP request handler for the bridge (testable as a factory).
 *
 * Routes:
 *   POST /webhook — Stripe checkout.session.completed webhooks
 *   GET  /lookup/<sessionId> — operator / incident-recovery lookup
 *
 * Exported for tests so they can drive the SAME handler logic the
 * production bridge server uses (instead of duplicating the route
 * dispatch in test code).
 */
function createRequestHandler() {
  return async function handleBridgeRequest(req, res) {
    if (req.method === 'POST' && req.url === '/webhook') {
      let rawBody;
      try {
        rawBody = await readRawBody(req);
      } catch (err) {
        writeJson(res, 413, { delivered: false, reason: 'body-too-large' });
        return;
      }

      const signatureHeader = req.headers['stripe-signature'] || '';
      let result;
      try {
        result = await handleWebhook({ rawBody, signatureHeader });
      } catch (err) {
        log('error', 'handler-threw', { error: err.message, stack: err.stack });
        writeJson(res, 500, { delivered: false, reason: 'handler-error' });
        return;
      }
      writeJson(res, result.status, result.body);
      return;
    }

    if (req.method === 'GET' && req.url && req.url.startsWith('/lookup/')) {
      // The bridge exposes a /lookup/<sessionId> endpoint as a convenience
      // for out-of-band operators (manual incident recovery, cron jobs that
      // scan pending_email records, etc.). The PRODUCTION lookup endpoint
      // for the success page is the API route at /api/v1/billing/lookup/*,
      // which reads the same fulfillment-store file but lives in the API
      // process (so the customer-facing response path doesn't depend on the
      // bridge being up). This endpoint is only useful when the API is
      // unreachable but the bridge is — and the bridge itself can fail to
      // boot without it.
      //
      // decodeURIComponent throws on malformed percent-encoding. We catch
      // that explicitly to surface a clean 400 instead of a 500.
      const rawSessionId = req.url.slice('/lookup/'.length).split('?')[0];
      let sessionId;
      try {
        sessionId = decodeURIComponent(rawSessionId);
      } catch (_) {
        writeJson(res, 400, { delivered: false, reason: 'invalid-session-id' });
        return;
      }
      const result = lookupSession(sessionId);
      const httpStatus = result.status === 'not_found' || result.status === 'expired' ? 404 : 200;
      writeJson(res, httpStatus, result);
      return;
    }

    writeJson(res, 404, { delivered: false, reason: 'not-found' });
  };
}

/**
 * Create an HTTP server bound to the bridge request handler. Returns the
 * server WITHOUT starting it — callers call `.listen(port)` themselves.
 *
 * Production entrypoint uses this factory; tests can use
 * `bridge.createRequestHandler()` to wire the same dispatch logic
 * without spinning up an HTTP server.
 */
function createServer() {
  return http.createServer(createRequestHandler());
}

// Module-level server variable. Created by `createServer()` only when the
// bridge is the entrypoint (`require.main === module`); tests + libraries
// that require the bridge leave this unset.
let server;

if (require.main === module) {
  server = createServer();
}

// ── Bootstrap ──────────────────────────────────────────────────────────────

function checkFatalConfig() {
  const missing = [];
  if (!WEBHOOK_SECRET) missing.push('STRIPE_WEBHOOK_SECRET');
  // At least one product must be configured for purchase.
  const configured = catalog.getConfiguredProducts().filter((p) => p.priceId);
  if (configured.length === 0) {
    const allEnvs = catalog.PRODUCTS.map((p) => p.priceEnv);
    missing.push(`at-least-one-of-${allEnvs.join('|')}`);
  }
  return missing;
}

/**
 * Module exports — for tests. Production entrypoint is the `if
 * (require.main === module)` block below.
 */
module.exports = {
  verifyStripeSignature,
  handleWebhook,
  readEvents,
  eventSeen,
  recordEvent,
  writeEvents,
  resolveProductFromSession,
  lookupSession,
  // Server factories — tests can call createRequestHandler() to wire
  // the same dispatch logic the production server uses, without
  // duplicating route decoding / status mapping in test code.
  createRequestHandler,
  createServer,
  // Constants exposed so tests can pin them when running in parallel.
  TOLERANCE_SECONDS,
  LOOKUP_TTL_MS,
  DELIVERY_LEASE_MS,
  MAX_BODY_BYTES,
};

if (require.main === module) {
  const missing = checkFatalConfig();
  if (missing.length > 0) {
    log('error', 'startup-misconfigured', { missing });
    process.exit(1);
  }

  server.listen(PORT, () => {
    const smtp = _smtpConfig();
    log('info', 'stripe-license-bridge-listening', {
      port: PORT,
      configuredProducts: catalog.getConfiguredProducts()
        .filter((p) => p.priceId)
        .map((p) => ({ id: p.id, durationDays: p.durationDays, amountCents: p.amountCents })),
      smtpConfigured: Boolean(smtp.host && smtp.from),
      eventsFile: EVENTS_FILE,
      fulfillmentFile: FULFILLMENT_STORE,
      lookupTtlMs: LOOKUP_TTL_MS,
    });
  });
}
