#!/usr/bin/env node
/**
 * Verification for the "Sanitize before storing" hardening:
 *  - src/lib/ingest/sanitize.ts  (string/number sanitizers + stored-receipt zod schema)
 *  - src/lib/export/csvGenerator.ts (CSV formula-injection neutralizer)
 *  - excel / pdf generators (values stay plain strings / literal text)
 *
 * Runs under plain Node ≥ 23.6 (native TypeScript type-stripping):
 *     node scripts/verify_sanitize.mjs
 *
 * Exit code 0 = all assertions passed, 1 = at least one failed.
 */
import { strict as assert } from "node:assert";
import ExcelJS from "exceljs";

import {
  SANITIZE_LIMITS,
  sanitizeText,
  sanitizeMultilineText,
  sanitizeUrl,
  sanitizeCurrency,
  sanitizeReceiptBatch,
  sanitizeReceipt,
  StoredReceiptSchema,
} from "../src/lib/ingest/sanitize.ts";
import {
  generateAccountingCsv,
  neutralizeFormulaPrefix,
} from "../src/lib/export/csvGenerator.ts";
import { generateDualSheetExcel } from "../src/lib/export/excelGenerator.ts";
import { generateReceiptPdf } from "../src/lib/export/pdfGenerator.ts";

// ---------------------------------------------------------------------------
// Tiny test runner
// ---------------------------------------------------------------------------

let passed = 0;
let failed = 0;
const failures = [];

function test(name, fn) {
  try {
    fn();
    passed++;
    console.log(`  PASS  ${name}`);
  } catch (err) {
    failed++;
    failures.push({ name, err });
    console.error(`  FAIL  ${name}\n        ${err && err.message ? err.message : err}`);
  }
}

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

function validReceipt(overrides = {}) {
  return {
    id: "rcpt_test_123",
    imageHash: "abc123def456",
    merchant: {
      name: "REWE",
      address: "Hauptstr 1, 10115 Berlin",
      taxId: "DE123456789",
      confidence: 0.98,
    },
    date: { isoDate: "2026-08-15", time: "10:30", confidence: 0.97 },
    documentType: "KASSENBON",
    receiptNumber: "2026-0815-001",
    currency: "EUR",
    totalAmount: { value: 12.34, confidence: 0.99 },
    netAmount: 10.36,
    tipAmount: null,
    taxBreakdown: [{ ratePercent: 19, taxAmount: 1.98, netAmount: 10.36 }],
    lineItems: [
      { description: "Apfel", quantity: 2, price: 12.34, unitPrice: 6.17, taxRate: 19 },
    ],
    suggestedCategory: "Sonstiges",
    validation: {
      isMathValid: true,
      isDuplicateSuspected: false,
      needsUserReview: false,
      reviewField: "none",
      reviewReason: null,
      issues: null,
      userConfirmed: null,
    },
    rawText: "REWE\nApfel 2x 6,17\nSUMME 12,34",
    paymentMethod: "EC_KARTE",
    previewUrl: "https://storage.example/rcpt_test_123.jpg",
    originalFileName: "bon.jpg",
    fileSizeBytes: 12345,
    status: "ready",
    createdAt: "2026-08-15T10:31:00.000Z",
    updatedAt: "2026-08-15T10:31:00.000Z",
    ...overrides,
  };
}

function maliciousReceipt() {
  return validReceipt({
    merchant: {
      name: "<script>alert(1)</script>",
      address: "<img src=x onerror=alert(2)>",
      taxId: "javascript:alert(3)",
      confidence: 1,
    },
    receiptNumber: '=HYPERLINK("http://evil.example","x")',
    suggestedCategory: "@SUM(1+1)",
    rawText: '<img src=x onerror=alert(4)>\n<b>Zeile 2</b>\nZeile 3\u0000\u0007',
    lineItems: [
      { description: "+cmd|' /C calc'!A0", quantity: 1, price: 12.34, unitPrice: 12.34, taxRate: 19 },
      { description: "<svg onload=alert(5)>", quantity: 2, price: 0.5, taxRate: null },
    ],
    validation: {
      isMathValid: true,
      isDuplicateSuspected: false,
      needsUserReview: true,
      reviewField: "merchant",
      reviewReason: "<script>alert(6)</script>",
      issues: [{ field: "merchant", severity: "warning", message: "<img onerror=alert(7)>" }],
      userConfirmed: null,
    },
    paymentMethod: "EC_KARTE",
  });
}

/** Splits one quoted CSV line (; delimiter) into unquoted cells. */
function splitCsvLine(line) {
  const cells = [];
  let cur = "";
  let inQuotes = false;
  for (let i = 0; i < line.length; i++) {
    const ch = line[i];
    if (inQuotes) {
      if (ch === '"') {
        if (line[i + 1] === '"') {
          cur += '"';
          i++;
        } else {
          inQuotes = false;
        }
      } else {
        cur += ch;
      }
    } else if (ch === '"') {
      inQuotes = true;
    } else if (ch === ";") {
      cells.push(cur);
      cur = "";
    } else {
      cur += ch;
    }
  }
  cells.push(cur);
  return cells;
}

// ---------------------------------------------------------------------------
// 1. String sanitizer
// ---------------------------------------------------------------------------

console.log("\n[1] String sanitizer");

test("strips script tags from merchant name", () => {
  assert.equal(sanitizeText("<script>alert(1)</script>", 200), "alert(1)");
});

test("strips img/svg onerror markup entirely (tag content included)", () => {
  // The whole tag — including payload text inside it — is removed.
  assert.equal(sanitizeText('<img src=x onerror=alert(1)>', 200), "");
  assert.equal(sanitizeText("<svg onload=alert(5)>", 200), "");
});

test("strips comments / doctype / CDATA / processing instructions", () => {
  assert.equal(sanitizeText("a<!-- x -->b", 200), "ab");
  assert.equal(sanitizeText("a<!DOCTYPE html>b", 200), "ab");
  assert.equal(sanitizeText("a<![CDATA[ x ]]>b", 200), "ab");
  assert.equal(sanitizeText("a<?php echo 1 ?>b", 200), "ab");
});

test("malformed double-angle markup cannot survive", () => {
  assert.equal(sanitizeText("<<script>alert(1)", 200), "alert(1)");
});

test("strips control chars (NUL, BEL, ESC …) and collapses whitespace", () => {
  assert.equal(sanitizeText("  Hello\u0000World\u0007!\t\t", 200), "HelloWorld!");
  assert.equal(sanitizeText("a\u0000\nb", 200), "a b");
});

test("trims and caps length", () => {
  assert.equal(sanitizeText("  x  ", 200), "x");
  assert.equal(sanitizeText("x".repeat(5000), 200).length, 200);
  assert.equal(sanitizeText(12345, 200), ""); // non-string → ""
});

test("multiline OCR sanitizer keeps newlines, strips markup and NUL", () => {
  const out = sanitizeMultilineText("<img src=x onerror=alert(4)>\n<b>Zeile 2</b>\nZeile 3\u0000\u0007", 1000);
  assert.equal(out, "Zeile 2\nZeile 3");
  assert.ok(!out.includes("<"));
});

test("multiline OCR caps at 50k", () => {
  assert.equal(sanitizeMultilineText("y".repeat(100000), 50000).length, 50000);
});

test("sanitizeUrl drops dangerous schemes and caps length", () => {
  assert.equal(sanitizeUrl("javascript:alert(1)"), undefined);
  assert.equal(sanitizeUrl("vbscript:msgbox(1)"), undefined);
  assert.equal(sanitizeUrl("data:text/html,<script>alert(1)</script>"), undefined);
  assert.equal(sanitizeUrl("  https://example.com/a.jpg  "), "https://example.com/a.jpg");
  assert.equal(sanitizeUrl("x".repeat(5000)).length, SANITIZE_LIMITS.previewUrl);
});

test("sanitizeCurrency normalizes and falls back", () => {
  assert.equal(sanitizeCurrency("  chf "), "CHF");
  assert.equal(sanitizeCurrency("EUR"), "EUR");
  assert.equal(sanitizeCurrency(""), "EUR");
  assert.equal(sanitizeCurrency(undefined), "EUR");
});

// ---------------------------------------------------------------------------
// 2. Formula-injection neutralizer (CSV export boundary)
// ---------------------------------------------------------------------------

console.log("\n[2] CSV formula-injection neutralizer");

test("prefixes = + - @ tab CR payloads with a single quote", () => {
  assert.equal(neutralizeFormulaPrefix('=HYPERLINK("http://evil","x")'), "'=HYPERLINK(\"http://evil\",\"x\")");
  assert.equal(neutralizeFormulaPrefix("+cmd|' /C calc'!A0"), "'+cmd|' /C calc'!A0");
  assert.equal(neutralizeFormulaPrefix("@SUM(1+1)"), "'@SUM(1+1)");
  assert.equal(neutralizeFormulaPrefix("-cmd|'/C calc'!A0"), "'-cmd|'/C calc'!A0");
  assert.equal(neutralizeFormulaPrefix("\t=1+1"), "'\t=1+1");
  assert.equal(neutralizeFormulaPrefix("\r=1+1"), "'\r=1+1");
});

test("leaves plain numbers (incl. negative credit notes) untouched", () => {
  assert.equal(neutralizeFormulaPrefix("-5,00"), "-5,00");
  assert.equal(neutralizeFormulaPrefix("+1,25"), "+1,25");
  assert.equal(neutralizeFormulaPrefix("123"), "123");
  assert.equal(neutralizeFormulaPrefix(""), "");
  assert.equal(neutralizeFormulaPrefix("'=already-safe"), "'=already-safe");
});

// ---------------------------------------------------------------------------
// 3. Stored-receipt schema: malicious input → sanitized safe output
// ---------------------------------------------------------------------------

console.log("\n[3] Stored-receipt schema (sanitize before store)");

test("malicious receipt is accepted and fully sanitized (no active markup)", () => {
  const result = sanitizeReceiptBatch({ receipts: [maliciousReceipt()] });
  assert.ok(result.ok, `expected ok, got: ${result.error}`);
  const r = result.receipts[0];

  assert.equal(r.merchant.name, "alert(1)");
  assert.equal(r.merchant.address, null); // whole <img …> tag removed → empty → null
  assert.equal(r.receiptNumber, '=HYPERLINK("http://evil.example","x")'); // inert text at rest
  assert.equal(r.suggestedCategory, "@SUM(1+1)"); // inert text at rest
  assert.equal(r.rawText, "Zeile 2\nZeile 3");
  assert.equal(r.lineItems[0].description, "+cmd|' /C calc'!A0");
  assert.equal(r.lineItems[1].description, ""); // <svg …> tag removed entirely
  assert.equal(r.validation.reviewReason, "alert(6)");
  assert.equal(r.validation.issues[0].message, ""); // <img …> tag removed entirely
  assert.equal(r.validation.issues[0].severity, "warning");

  const serialized = JSON.stringify(r);
  assert.ok(!serialized.includes("<"), "no angle bracket may survive in stored data");
  assert.ok(!serialized.includes("\u0000"), "no NUL may survive in stored data");
});

test("markup inside date / receiptNumber is stripped, not rejected", () => {
  const result = sanitizeReceiptBatch(
    validReceipt({ date: { isoDate: "2026-<b>08</b>-15", time: null, confidence: 0.9 } })
  );
  assert.ok(result.ok);
  assert.equal(result.receipts[0].date.isoDate, "2026-08-15");
});

test("legacy German date format is preserved (sanitized), not rejected", () => {
  const result = sanitizeReceiptBatch(
    validReceipt({ date: { isoDate: "15.08.2026", time: null, confidence: 0.9 } })
  );
  assert.ok(result.ok);
  assert.equal(result.receipts[0].date.isoDate, "15.08.2026");
});

test("oversized strings are truncated per policy", () => {
  const result = sanitizeReceiptBatch(
    validReceipt({ merchant: { name: "X".repeat(5000), address: null, taxId: null, confidence: 1 } })
  );
  assert.ok(result.ok);
  assert.equal(result.receipts[0].merchant.name.length, SANITIZE_LIMITS.merchantName);
});

// ---------------------------------------------------------------------------
// 4. Numbers
// ---------------------------------------------------------------------------

console.log("\n[4] Numbers");

test("negative amounts are kept (credit notes are a domain case)", () => {
  const result = sanitizeReceiptBatch(
    validReceipt({ totalAmount: { value: -50, confidence: 1 }, netAmount: -40 })
  );
  assert.ok(result.ok);
  assert.equal(result.receipts[0].totalAmount.value, -50);
  assert.equal(result.receipts[0].netAmount, -40);
});

test("huge amounts are clamped to ±1e10 (fits numeric(12,2))", () => {
  const result = sanitizeReceiptBatch(
    validReceipt({ totalAmount: { value: 1e12, confidence: 1 } })
  );
  assert.ok(result.ok);
  assert.equal(result.receipts[0].totalAmount.value, SANITIZE_LIMITS.maxAmountAbs);
});

test("tax rate percent is clamped to 0..100", () => {
  const result = sanitizeReceiptBatch(
    validReceipt({
      taxBreakdown: [{ ratePercent: 150, taxAmount: 10, netAmount: 100 }],
    })
  );
  assert.ok(result.ok);
  assert.equal(result.receipts[0].taxBreakdown[0].ratePercent, 100);
});

test("NaN / Infinity amounts are REJECTED (structurally invalid)", () => {
  const nan = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: NaN, confidence: 1 } }));
  assert.ok(!nan.ok);
  assert.ok(nan.error.includes("totalAmount.value"), nan.error);

  const inf = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: Infinity, confidence: 1 } }));
  assert.ok(!inf.ok);
});

test("non-number in a number field is rejected", () => {
  const result = sanitizeReceiptBatch(validReceipt({ totalAmount: { value: "12.5", confidence: 1 } }));
  assert.ok(!result.ok);
});

// ---------------------------------------------------------------------------
// 5. Currency
// ---------------------------------------------------------------------------

console.log("\n[5] Currency");

test("3-letter ISO currency passes, lowercase is normalized", () => {
  const ok = sanitizeReceiptBatch(validReceipt({ currency: "chf" }));
  assert.ok(ok.ok);
  assert.equal(ok.receipts[0].currency, "CHF");
});

test("missing currency defaults to EUR", () => {
  const r = validReceipt();
  delete r.currency;
  const ok = sanitizeReceiptBatch(r);
  assert.ok(ok.ok);
  assert.equal(ok.receipts[0].currency, "EUR");
});

test("bad currency (symbol / 2-letter / number) is rejected with 400-style error", () => {
  for (const bad of ["€", "US", "euro", 123]) {
    const result = sanitizeReceiptBatch(validReceipt({ currency: bad }));
    assert.ok(!result.ok, `currency ${JSON.stringify(bad)} should be rejected`);
    assert.ok(result.error.includes("currency"), result.error);
  }
});

// ---------------------------------------------------------------------------
// 6. Batch shape handling (mirrors POST /api/receipts)
// ---------------------------------------------------------------------------

console.log("\n[6] Batch shapes & limits");

test("accepts array, {receipts}, {receipt} and single-receipt bodies", () => {
  const base = validReceipt();
  assert.ok(sanitizeReceiptBatch([base]).ok);
  assert.ok(sanitizeReceiptBatch({ receipts: [base] }).ok);
  assert.ok(sanitizeReceiptBatch({ receipt: base }).ok);
  assert.ok(sanitizeReceiptBatch(base).ok);
});

test("rejects empty payloads and >500 receipts with the historic 400 messages", () => {
  assert.equal(sanitizeReceiptBatch({}).error, "No receipts provided in payload.");
  assert.equal(sanitizeReceiptBatch([]).error, "No receipts provided in payload.");
  assert.equal(sanitizeReceiptBatch(Array(501).fill(validReceipt())).error, "Too many receipts in payload.");
});

test("rejects structurally invalid receipts with a clear indexed error", () => {
  const broken = validReceipt({ merchant: undefined });
  const result = sanitizeReceiptBatch({ receipts: [validReceipt(), broken] });
  assert.ok(!result.ok);
  assert.ok(result.error.includes("index 1"), result.error);
  assert.ok(result.error.includes("merchant"), result.error);
});

test("rejects missing / oversized receipt ids", () => {
  assert.ok(!sanitizeReceiptBatch(validReceipt({ id: "" })).ok);
  assert.ok(!sanitizeReceiptBatch(validReceipt({ id: "x".repeat(100) })).ok);
});

test("sanitizeReceipt single variant returns null on failure, sanitized data on success", () => {
  assert.equal(sanitizeReceipt(validReceipt({ currency: "€" })), null);
  const r = sanitizeReceipt(validReceipt({ merchant: { name: "<b>REWE</b>", address: null, taxId: null, confidence: 1 } }));
  assert.ok(r !== null);
  assert.equal(r.merchant.name, "REWE");
});

test("StoredReceiptSchema keeps passthrough fields (boundingBoxes etc.)", () => {
  const r = validReceipt({ boundingBoxes: { merchant: { x: 1, y: 2, width: 3, height: 4 } } });
  const parsed = StoredReceiptSchema.safeParse(r);
  assert.ok(parsed.success);
  assert.deepEqual(parsed.data.boundingBoxes, r.boundingBoxes);
});

// ---------------------------------------------------------------------------
// 7. CSV generator: no cell may start with a formula character
// ---------------------------------------------------------------------------

console.log("\n[7] CSV generator output");

function evilCsvReceipt() {
  return validReceipt({
    merchant: { name: '=HYPERLINK("http://evil.example","x")', address: null, taxId: null, confidence: 1 },
    receiptNumber: "+cmd|' /C calc'!A0",
    suggestedCategory: "@SUM(1+1)",
    rawText: "<img src=x onerror=alert(1)>\n<b>bold</b>",
    taxBreakdown: [{ ratePercent: 19, taxAmount: 1.98, netAmount: 10.36 }],
  });
}

test("malicious merchant/receiptNumber/category cells are neutralized in CSV", () => {
  const csv = generateAccountingCsv([evilCsvReceipt()], { locale: "de" });

  assert.ok(!csv.includes('"=HYPERLINK'), "raw formula must not appear quoted");
  assert.ok(csv.includes("'=HYPERLINK"), "neutralized cell must be prefixed with a quote");
  assert.ok(csv.includes("'+cmd|"), "DDE payload must be neutralized");

  const lines = csv.replace(/^\uFEFF/, "").split("\r\n").filter((l) => l.length > 0);
  assert.ok(lines.length >= 2, "header + at least one data row");

  for (let i = 1; i < lines.length; i++) {
    for (const cell of splitCsvLine(lines[i])) {
      assert.ok(
        !/^[=+\-@\t\r]/.test(cell),
        `CSV cell on line ${i + 1} starts with a formula char: ${JSON.stringify(cell)}`
      );
    }
  }
});

test("negative credit-note amounts still export as plain numbers", () => {
  const csv = generateAccountingCsv(
    [validReceipt({ totalAmount: { value: -50, confidence: 1 }, netAmount: -40, currency: "EUR" })],
    { locale: "de" }
  );
  assert.ok(csv.includes('"-50,00"'), "negative amount stays a number cell");
});

// ---------------------------------------------------------------------------
// 8. Excel generator: user values are written as plain strings, never formulas
// ---------------------------------------------------------------------------

console.log("\n[8] Excel generator round-trip");

test("merchant '=HYPERLINK(...)' is written as a plain STRING cell, not a formula", async () => {
  const buffer = await generateDualSheetExcel([evilCsvReceipt()], { locale: "de" });
  const wb = new ExcelJS.Workbook();
  await wb.xlsx.load(buffer);

  const sheet = wb.getWorksheet("Belegübersicht");
  assert.ok(sheet, "overview sheet exists");
  const merchantCell = sheet.getCell(2, 3); // col C = merchant
  assert.equal(typeof merchantCell.value, "string");
  assert.equal(merchantCell.value, '=HYPERLINK("http://evil.example","x")');
  assert.equal(merchantCell.formula, undefined, "cell must not carry a formula");

  const itemsSheet = wb.getWorksheet("Einzelpositionen Detail");
  assert.ok(itemsSheet, "line-items sheet exists");
  const descCell = itemsSheet.getCell(2, 4); // col D = description
  assert.equal(typeof descCell.value, "string");
  assert.equal(descCell.formula, undefined, "description cell must not carry a formula");
});

// ---------------------------------------------------------------------------
// 9. PDF generator: renders literal text, no formula/HTML interpretation
// ---------------------------------------------------------------------------

console.log("\n[9] PDF generator");

test("PDF with malicious strings still generates a valid PDF document", async () => {
  const pdf = await generateReceiptPdf([evilCsvReceipt()], { locale: "de" });
  assert.ok(pdf instanceof Uint8Array && pdf.length > 100);
  const header = Buffer.from(pdf.slice(0, 5)).toString("latin1");
  assert.equal(header, "%PDF-");
});

// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------

console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) {
  console.error("\nFailed tests:");
  for (const f of failures) console.error(`  - ${f.name}: ${f.err.message}`);
  process.exit(1);
}
process.exit(0);
