/**
 * Invoice rendering tests — DC-058.
 *
 * Pure functions. No live network, no SMTP, no Stripe SDK. Covers:
 *   - HTML escaping for every user-controlled field
 *   - CRLF/control-char neutralization (SMTP header injection defense)
 *   - Plain-text fallback has the same content
 *   - PDF is a valid PDF (magic bytes + loadable by pdf-parse)
 *   - Invoice number derived from event id (deterministic)
 *   - Catalog integration: missing productId still produces valid output
 *
 * Pairs with stripe-license-bridge.test.js (which covers the SMTP wiring
 * on top of these primitives).
 */

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

const invoice = require('../../src/billing/invoice');
const catalog = require('../../src/billing/catalog');

// pdf-parse is the canonical tool to extract text from a PDF buffer for
// verification. We keep it as a soft dependency — if it's not available,
// the text-content tests skip rather than fail.
let pdfParse = null;
try {
  pdfParse = require('pdf-parse');
} catch (_) {
  pdfParse = null;
}

const BASE = {
  email: 'alice@example.com',
  customerName: 'Alice Johnson',
  code: 'DC-PRO-30D-AB12CD34',
  durationDays: 30,
  productLabel: '1 month',
  productId: 'pro-30d',
  amountCents: 2000,
  currency: 'USD',
  eventId: 'evt_4f2c9b3a8b1d',
  sessionId: 'cs_test_a1b2c3d4e5',
  supportUrl: 'https://dashcaddy.net',
};

describe('billing/invoice', () => {
  describe('generateInvoiceNumber', () => {
    test('strips evt_ prefix and produces INV-{8 hex chars}', () => {
      expect(invoice.generateInvoiceNumber('evt_4f2c9b3a8b1d')).toBe('INV-4F2C9B3A');
    });

    test('uppercases mixed-case event ids', () => {
      expect(invoice.generateInvoiceNumber('evt_AbCdEf1234')).toBe('INV-ABCDEF12');
    });

    test('falls back to NOEVENT for empty/missing input', () => {
      expect(invoice.generateInvoiceNumber('')).toBe('INV-NOEVENT');
      expect(invoice.generateInvoiceNumber(null)).toBe('INV-NOEVENT');
      expect(invoice.generateInvoiceNumber(undefined)).toBe('INV-NOEVENT');
    });

    test('handles event id without prefix', () => {
      expect(invoice.generateInvoiceNumber('4f2c9b3a8b1d')).toBe('INV-4F2C9B3A');
    });
  });

  describe('stripControlChars', () => {
    test('replaces CRLF with single space (prevents SMTP header injection)', () => {
      const input = 'alice@example.com\r\nBcc: attacker@evil.com';
      const output = invoice.stripControlChars(input);
      expect(output).toBe('alice@example.com Bcc: attacker@evil.com');
      expect(output).not.toContain('\r');
      expect(output).not.toContain('\n');
    });

    test('collapses whitespace runs', () => {
      expect(invoice.stripControlChars('  alice   example  ')).toBe('alice example');
    });

    test('handles null/undefined gracefully', () => {
      expect(invoice.stripControlChars(null)).toBe('');
      expect(invoice.stripControlChars(undefined)).toBe('');
    });

    test('preserves printable unicode (accents, emoji)', () => {
      expect(invoice.stripControlChars('Sami Ahmed 🚀')).toBe('Sami Ahmed 🚀');
    });
  });

  describe('escapeHtml', () => {
    test('escapes all HTML metacharacters', () => {
      expect(invoice.escapeHtml('<script>alert(1)</script>'))
        .toBe('&lt;script&gt;alert(1)&lt;/script&gt;');
      expect(invoice.escapeHtml(`"O'Brien & Sons"`))
        .toBe('&quot;O&#39;Brien &amp; Sons&quot;');
    });

    test('handles null/undefined', () => {
      expect(invoice.escapeHtml(null)).toBe('');
      expect(invoice.escapeHtml(undefined)).toBe('');
    });
  });

  describe('renderLicenseEmailHtml', () => {
    test('renders branded HTML with license code, invoice number, and price', () => {
      const { subject, html } = invoice.renderLicenseEmailHtml(BASE);
      expect(subject).toContain('DashCaddy Pro');
      expect(subject).toContain('30 days');
      expect(html).toContain('DC-PRO-30D-AB12CD34');
      expect(html).toContain('INV-4F2C9B3A');
      expect(html).toContain('$20.00');
      expect(html).toContain('Alice');  // first name from customerName
      expect(html).toContain('alice@example.com');
      // Brand colors must match the rest of DashCaddy
      expect(html).toContain('#09111f');  // bg
      expect(html).toContain('#7cf2c0');  // pro accent
      expect(html).toContain('#68a4ff');  // accent
    });

    test('uses a friendly greeting when customerName is missing', () => {
      const { html } = invoice.renderLicenseEmailHtml({ ...BASE, customerName: '' });
      expect(html).toContain('Hi there,');
      expect(html).not.toContain('Hi ,');
    });

    test('does NOT include any CR or LF in user-controlled regions (CRLF injection defense)', () => {
      // Use NO-SPACE-after-colon payloads so that if `stripControlChars`
      // were deleted, the rendered output would contain "Bcc:attacker"
      // (header-injection survivors, no spaces between the colon and value).
      // The earlier version used "Bcc: attacker" (with space) which the
      // rendered output also had — the regex /Bcc:[^\s<]/ could not match
      // either way, so the test passed vacuously regardless of whether
      // sanitization actually ran.
      const malicious = {
        ...BASE,
        email: 'alice@example.com\r\nBcc:attacker@evil.com',
        customerName: 'Eve\r\nBcc:eve@evil.com',
        code: 'X\r\nY',
        eventId: 'evt_\r\nfakeHeader:1',
      };
      const { html } = invoice.renderLicenseEmailHtml(malicious);
      // CRITICAL: no \r anywhere (template source has no \r).
      expect(html).not.toMatch(/\r/);
      // Extract each user-controlled region and assert no \n AND no
      // unbroken "Bcc:<value>" header-injection survivors. Each region
      // comes from the email/customerName/code/eventId values; if any
      // contains a \n OR a "Bcc:" without a space-after-colon, the test
      // fails. This is the strongest possible assertion: deleting
      // stripControlChars would break it immediately.
      const patterns = [
        { name: 'email',   re: /Email[^<]*<a[^>]+>([^<]+)<\/a>/ },
        { name: 'name',    re: /(?:Thanks for your purchase, |Hi )([^<!,]+)/ },
        { name: 'code',    re: /<div[^>]*word-break[^>]*>([^<]+)<\/div>/ },
        { name: 'eventId', re: /Stripe event[^<]*<a[^>]+>([^<]+)<\/a>/ },
      ];
      for (const { name, re } of patterns) {
        const m = html.match(re);
        if (m) {
          expect(m[1]).not.toMatch(/\n/);
          expect(m[1]).not.toMatch(/Bcc:[^\s<]/);  // header-injection survivor
          expect(m[1]).not.toMatch(/fakeHeader:[^\s<]/);
        }
      }
    });

    test('escapes HTML in customer name (XSS defense)', () => {
      const { html } = invoice.renderLicenseEmailHtml({
        ...BASE,
        customerName: '<script>alert(1)</script>',
      });
      expect(html).not.toContain('<script>');
      expect(html).toContain('&lt;script&gt;');
    });

    test('escapes HTML in email address', () => {
      const { html } = invoice.renderLicenseEmailHtml({
        ...BASE,
        email: '" onclick="alert(1)"@evil.com',
      });
      expect(html).not.toContain('onclick="alert(1)"');
      expect(html).toContain('&quot;');
    });

    test('falls back to productLabel from catalog when not provided', () => {
      const { html } = invoice.renderLicenseEmailHtml({
        ...BASE,
        productLabel: undefined,
      });
      expect(html).toContain('1 month');  // catalog label for pro-30d
    });

    test('formats price as $XX.XX always with 2 decimals', () => {
      const { html } = invoice.renderLicenseEmailHtml({ ...BASE, amountCents: 9900 });
      expect(html).toContain('$99.00');
    });

    test('non-USD currency shows native symbol (EUR, GBP, JPY)', () => {
      expect(invoice.renderLicenseEmailHtml({ ...BASE, currency: 'EUR', amountCents: 5000 }).html)
        .toContain('€50.00');
      expect(invoice.renderLicenseEmailHtml({ ...BASE, currency: 'GBP', amountCents: 3500 }).html)
        .toContain('£35.00');
      expect(invoice.renderLicenseEmailHtml({ ...BASE, currency: 'JPY', amountCents: 200000 }).html)
        .toContain('¥2000.00');
    });

    test('unknown currency falls back to ISO code suffix (never bare amount)', () => {
      // 9999 cents = $99.99 in major units
      const text = invoice.renderLicenseEmailText({ ...BASE, currency: 'XYZ', amountCents: 9999 });
      expect(text).toContain('99.99 XYZ');
      expect(text).not.toMatch(/99\.99\s*$/);  // no trailing currency — must end with code
    });

    test('rejects non-http(s) supportUrl schemes (javascript:, data:, file:)', () => {
      // Each of these would render in the customer's email client if it
      // slipped through. The bridge controls the value today, but defense-
      // in-depth: an allow-list is cheaper than an XSS incident.
      for (const badUrl of [
        'javascript:alert(1)',
        'data:text/html,<script>alert(1)</script>',
        'file:///etc/passwd',
        'vbscript:msgbox(1)',
        'ftp://example.com',
      ]) {
        const { html } = invoice.renderLicenseEmailHtml({ ...BASE, supportUrl: badUrl });
        expect(html).not.toContain('javascript:');
        expect(html).not.toContain('data:text/html');
        expect(html).not.toContain('file:///');
        expect(html).not.toContain('vbscript:');
        // Falls back to the canonical https URL.
        expect(html).toContain('https://dashcaddy.net');
      }
    });

    test('long license code (>24 chars) wraps instead of overflowing PDF', async () => {
      // 50-char code would overflow the 484px Courier-Bold box at 13pt.
      const longCode = 'DC-PRO-30D-' + 'X'.repeat(40);
      const buf = await invoice.renderInvoicePdf({ ...BASE, code: longCode });
      expect(buf.length).toBeGreaterThan(1000);
      // PDFKit handles lineBreak:true by wrapping inside the box; we just
      // need to verify the PDF is structurally valid (parsed by pdf-parse).
      const pdfParse = require('pdf-parse');
      const { text } = await pdfParse(buf);
      // The key body should be in there somewhere — even if wrapped across
      // lines, at least part of the code is extractable.
      expect(text).toMatch(/DC-PRO-30D/);
    });

    test('PDF Info Subject is constant (does NOT echo customer name or email)', async () => {
      // A customer-influenceable string in PDF metadata (visible in every
      // PDF reader's Properties panel) is a phishing-recon signal even
      // though it's not XSS-executable. The Subject field MUST be a
      // constant; the customer-identifying info lives in the visible body.
      const buf = await invoice.renderInvoicePdf({
        ...BASE,
        customerName: '<script>alert(1)</script>',
        email: 'evil@attacker.com',
      });
      const pdfParse = require('pdf-parse');
      // Pass version option to extract metadata (some pdf-parse versions
      // require explicit hint to parse Info dictionary).
      const { metadata, text } = await pdfParse(buf, { version: 'default' });
      // If pdf-parse still doesn't extract metadata, fall back to scanning
      // the binary for the Subject string. Either way, the assertion holds.
      if (metadata) {
        expect(metadata.Subject).toBe('DashCaddy Pro invoice');
      } else {
        // The Subject is stored as an indirect object reference in the PDF;
        // it might not parse cleanly. Look for the constant in the binary
        // string form (PDFKit may encode it as UTF-16BE or octal escapes).
        const bin = buf.toString('binary');
        // The escaped form of "DashCaddy Pro invoice" in PDF literal strings
        // is the literal text wrapped in parentheses, possibly octal-escaped.
        // We just verify the email/HTML-payload is NOT in the metadata object
        // references — search for the literal Subject string body.
        const subjectObj = bin.match(/\/Subject\s*\(([^)]+)\)/);
        if (subjectObj) {
          expect(subjectObj[1]).not.toContain('evil@attacker.com');
          expect(subjectObj[1]).not.toContain('<script>');
          expect(subjectObj[1]).toMatch(/DashCaddy/);
        }
      }
      // The visible body can include the email (Bill To) but NOT the
      // XSS payload — that's escaped to text by escapeHtml() in renderInvoicePdf.
      expect(text).not.toContain('<script>alert(1)</script>');
    });

    test('rejects non-numeric amountCents (string "2000" would silently render $0.00)', () => {
      // STRING amount used to silently fall through to $0.00 because
      // Number.isFinite('2000') is false. Now we throw, surfacing the bug
      // at the bridge instead of shipping a $0 invoice to a paying customer.
      // We strip productId so the catalog fallback doesn't rescue the bad input.
      const { productId, ...baseNoProduct } = BASE;
      expect(() => invoice.renderLicenseEmailHtml({ ...baseNoProduct, amountCents: '2000' }))
        .toThrow(/amountCents must be a positive integer/);
    });

    test('rejects NaN, Infinity, negative, and zero amountCents', () => {
      const { productId, ...baseNoProduct } = BASE;
      for (const bad of [NaN, Infinity, -Infinity, -100, 0]) {
        expect(() => invoice.renderLicenseEmailHtml({ ...baseNoProduct, amountCents: bad }))
          .toThrow(/amountCents must be a positive integer/);
      }
    });

    test('falls back to catalog amount when amountCents is null AND productId resolves', () => {
      // Bridge contract: if amountCents is missing from the Stripe session
      // (older sessions, expand failure), we use the catalog's canonical
      // price rather than throwing. This is the recovery path.
      const html = invoice.renderLicenseEmailHtml({
        ...BASE,
        productId: 'pro-30d',
        amountCents: null,
      }).html;
      // catalog says pro-30d = $20.00 (2000 cents)
      expect(html).toContain('$20.00');
    });

    test('fractional cents are floored (no silent $0.01 from $0.005 rounding)', () => {
      // 2000.7 cents should render as $20.00 (floored). The bridge should
      // never send fractional cents in practice, but defense-in-depth.
      const html = invoice.renderLicenseEmailHtml({ ...BASE, amountCents: 2000.7 }).html;
      expect(html).toContain('$20.00');
      expect(html).not.toContain('$20.01');
    });

    test('uses embedded SVG logo (works offline, no remote fetch)', () => {
      const { html } = invoice.renderLicenseEmailHtml(BASE);
      expect(html).toMatch(/src="data:image\/svg\+xml/);
      expect(html).not.toMatch(/src="https?:\/\//);
    });
  });

  describe('renderLicenseEmailText', () => {
    test('includes license code, invoice #, and amount', () => {
      const text = invoice.renderLicenseEmailText(BASE);
      expect(text).toContain('DC-PRO-30D-AB12CD34');
      expect(text).toContain('INV-4F2C9B3A');
      expect(text).toContain('$20.00');
      expect(text).toContain('Stripe event');
      expect(text).toContain('evt_4f2c9b3a8b1d');
    });

    test('uses first name from customerName when present', () => {
      const text = invoice.renderLicenseEmailText({
        ...BASE,
        customerName: 'Alice Johnson',
      });
      expect(text.split('\n')[0]).toBe('Hi Alice,');
    });

    test('falls back to "Hi there," when customerName missing', () => {
      const text = invoice.renderLicenseEmailText({ ...BASE, customerName: '' });
      expect(text.split('\n')[0]).toBe('Hi there,');
    });
  });

  describe('renderInvoicePdf', () => {
    test('produces a valid PDF (magic bytes + non-trivial size)', async () => {
      const buf = await invoice.renderInvoicePdf(BASE);
      expect(buf.length).toBeGreaterThan(1000);
      expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
      // PDF must end with %%EOF (or trailing newline + %%EOF)
      const tail = buf.slice(-32).toString('ascii');
      expect(tail).toContain('%%EOF');
    });

    test('PDF contains the license code (visible text)', async () => {
      if (typeof pdfParse !== 'function') return;  // soft skip if pdf-parse unavailable
      const buf = await invoice.renderInvoicePdf(BASE);
      const { text } = await pdfParse(buf);
      expect(text).toContain('DC-PRO-30D-AB12CD34');
    });

    test('PDF contains the invoice number and amount', async () => {
      if (typeof pdfParse !== 'function') return;
      const buf = await invoice.renderInvoicePdf(BASE);
      const { text } = await pdfParse(buf);
      expect(text).toContain('INV-4F2C9B3A');
      expect(text).toContain('20.00');
    });

    test('PDF includes customer name and email in bill-to', async () => {
      if (typeof pdfParse !== 'function') return;
      const buf = await invoice.renderInvoicePdf(BASE);
      const { text } = await pdfParse(buf);
      expect(text).toContain('Alice Johnson');
      expect(text).toContain('alice@example.com');
    });

    test('rejects when code is missing', () => {
      // The invoice builder now returns a rejected promise for invalid input
      // (validated synchronously, surfaced via Promise.reject before any PDFKit
      // allocation). Use .rejects for the async side and the sync-style
      // expect().toThrow for the inline check.
      return expect(invoice.renderInvoicePdf({ ...BASE, code: '' }))
        .rejects.toThrow('code is required');
    });
  });

  describe('catalog integration', () => {
    test('all 4 catalog products render without throwing', async () => {
      const products = catalog.listProducts();
      for (const product of products) {
        const input = {
          ...BASE,
          productId: product.id,
          productLabel: product.label,
          durationDays: product.durationDays,
          amountCents: product.amountCents,
        };
        const { subject, html } = invoice.renderLicenseEmailHtml(input);
        expect(subject).toContain(`${product.durationDays} days`);
        expect(html).toContain(`$${(product.amountCents / 100).toFixed(2)}`);

        const pdf = await invoice.renderInvoicePdf(input);
        expect(pdf.slice(0, 4).toString('ascii')).toBe('%PDF');

        if (typeof pdfParse === 'function') {
          const { text } = await pdfParse(pdf);
          expect(text).toContain(product.label);
        }
      }
    });
  });

  describe('security: XSS via customer-controlled fields', () => {
    // These should all escape, not execute. We don't render the email
    // anywhere — this is just defense-in-depth at the template layer.
    test.each([
      ['customerName', '<img src=x onerror=alert(1)>'],
      ['email', '"><script>alert(1)</script>'],
      ['code', '"><script>alert(1)</script>'],
      ['eventId', '"><script>alert(1)</script>'],
      ['sessionId', '"><script>alert(1)</script>'],
    ])('field %s XSS payload is escaped', async (field, payload) => {
      const { html } = invoice.renderLicenseEmailHtml({ ...BASE, [field]: payload });
      // The exact attack strings must not appear unescaped.
      expect(html).not.toContain(payload);
      // Escaped versions should be present (defense-in-depth visible).
      expect(html).toContain('&lt;');
    });

    test('img tag with onerror handler is fully escaped', () => {
      const { html } = invoice.renderLicenseEmailHtml({
        ...BASE,
        customerName: '<img src=x onerror=alert(1)>',
      });
      // The payload is HTML-escaped: < and > become &lt; / &gt;
      expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
      // The dangerous literal pattern must not appear.
      expect(html).not.toMatch(/<img[^>]+onerror/i);
    });
  });
});