import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { z } from 'zod';
import { documentExamples, validateDocument, calculateDocument, documentSchema, type BusinessDocument } from '../../src/tools/document/model';
import { createDocumentTemplate, generateDocumentPdf } from '../../src/tools/document/pdf';
import { inspectLogo, inspectLogoDataUrl } from '../../src/tools/document/logo';

const quote = () => structuredClone(documentExamples.quotation) as Extract<BusinessDocument, { pricing: true }>;
test('document: the independently specified half-up fixture totals exactly 840.00', () => {
  const data = quote(); data.discount = '49.88'; data.taxes = [{ name: 'Tax 1', rate: '5' }, { name: 'Tax 2', rate: '7' }];
  assert.equal(validateDocument(data).ok, true);
  const totals = calculateDocument(data); assert.equal(totals.pricing, true); if (!totals.pricing) return;
  assert.deepEqual(totals.lines.map(line => line.total), ['499.88', '300.00']);
  assert.equal(totals.subtotal, '799.88'); assert.equal(totals.discount, '49.88'); assert.equal(totals.taxable, '750.00');
  assert.deepEqual(totals.taxes.map(tax => tax.amount), ['37.50', '52.50']); assert.equal(totals.total, '840.00');
});
test('document: rounds each line before summing and handles zero/full discount without compounding tax', () => {
  const data = quote(); data.items = [1, 2, 3].map(n => ({ id: `i${n}`, description: 'Decimal fixture', quantity: '1', unitPrice: '0.005' }));
  let totals = calculateDocument(data); assert.equal(totals.pricing && totals.total, '0.03');
  data.discount = '0.03'; data.taxes = [{ name: 'First', rate: '100' }, { name: 'Second', rate: '100' }];
  totals = calculateDocument(data); assert.equal(totals.pricing && totals.total, '0.00');
  data.discount = '0'; data.items = [{ id: 'i1', description: 'Tax fixture', quantity: '1', unitPrice: '100' }];
  totals = calculateDocument(data); assert.equal(totals.pricing && totals.total, '300.00');
});
test('document: hidden pricing is absent from data, calculations, template and PDF inputs', () => {
  for (const id of ['work-order', 'service-report'] as const) {
    const data = documentExamples[id]; assert.equal(validateDocument(data).ok, true);
    assert.equal(calculateDocument(data).pricing, false);
    const bundle = createDocumentTemplate(data); const text = JSON.stringify(bundle);
    for (const forbidden of ['Subtotal:', 'Discount:', 'TOTAL:', 'Tax 1', 'Unit price', 'CAD ', 'USD ']) assert.equal(text.includes(forbidden), false, forbidden);
    assert.equal(validateDocument({ ...data, currency: 'CAD' }).ok, false);
    assert.equal(validateDocument({ ...data, items: data.items.map(item => ({ ...item, unitPrice: '100' })) }).ok, false);
  }
});
test('document: all templates and page sizes produce strict, reusable, JSON-compatible models', () => {
  for (const original of Object.values(documentExamples)) for (const paper of ['A4', 'Letter'] as const) {
    const data = { ...original, paper }; assert.equal(validateDocument(JSON.parse(JSON.stringify(data))).ok, true);
    const { template, inputs } = createDocumentTemplate(data); assert.equal(typeof template.basePdf, 'object');
    assert.ok(inputs[0].document_title); assert.equal(template.pdfmeVersion, '6.1.12');
    assert.ok(template.schemas[0].every(schema => ['text', 'table', 'image'].includes(schema.type)));
  }
  const schema = z.toJSONSchema(documentSchema); assert.ok(JSON.stringify(schema).includes('schemaVersion'));
});
test('document: rejects invalid numbers, dates, excessive discounts and unsafe or unsupported imported values', () => {
  for (const number of ['-1', 'NaN', 'Infinity', '1e3', '1,000', '0x10', '', '1.1234567', '100000001']) {
    const data = quote(); data.items[0].unitPrice = number; assert.equal(validateDocument(data).ok, false, number);
  }
  for (const date of ['2025-02-29', '2026-13-01', '2026-04-31', '6/9/2026', '2200-01-01']) assert.equal(validateDocument({ ...quote(), date }).ok, false, date);
  assert.equal(validateDocument({ ...quote(), date: '2024-02-29' }).ok, true);
  assert.equal(validateDocument({ ...quote(), validUntil: '2025-01-01' }).ok, false);
  assert.equal(validateDocument({ ...quote(), discount: '1000' }).ok, false);
  assert.equal(validateDocument({ ...quote(), schemaVersion: 99 }).ok, false);
  assert.equal(validateDocument({ ...quote(), plugin: 'custom' }).ok, false);
  assert.equal(validateDocument(JSON.parse('{"__proto__":{"polluted":true}}')).ok, false);
  assert.equal(validateDocument({ ...quote(), business: { name: 'Business \u0001', contact: '' } }).ok, false);
  const duplicate = quote(); duplicate.items[1].id = duplicate.items[0].id; assert.equal(validateDocument(duplicate).ok, false);
  const huge = quote(); huge.notes = 'a'.repeat(8001); assert.equal(validateDocument(huge).ok, false);
  assert.equal(validateDocument({ ...quote(), items: Array.from({ length: 201 }, (_, i) => ({ id: `i${i}`, description: 'Item', quantity: '1', unitPrice: '1' })) }).ok, false);
  const circular: Record<string, unknown> = {}; circular.child = circular; assert.equal(validateDocument(circular).ok, false);
});
const tinyPng = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAIAAAA7ljmRAAAAE0lEQVR4nGMUtzZlgAEmOAudAwAUAQCNQsu+0wAAAABJRU5ErkJggg==';
// Original solid-colour test fixture, generated locally; no customer or site asset.
const tinyJpeg = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAADAAQDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDy+iiirPPP/9k=';
test('document: accepts a bounded PNG signature and rejects SVG, remote, type-spoofed, truncated and oversized images', () => {
  const image = inspectLogoDataUrl(tinyPng); assert.equal(image.mime, 'image/png'); assert.equal(image.width, 4); assert.equal(image.height, 3);
  assert.equal(validateDocument({ ...quote(), logo: tinyPng }).ok, true);
  assert.equal(validateDocument({ ...quote(), logo: tinyJpeg }).ok, true);
  assert.equal(inspectLogoDataUrl(tinyJpeg).width, 4);
  for (const logo of ['https://example.com/logo.png', 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', tinyPng.replace('image/png', 'image/jpeg'), 'data:image/png;base64,PHN2Zz48L3N2Zz4=']) assert.equal(validateDocument({ ...quote(), logo }).ok, false);
  assert.throws(() => inspectLogo(image.bytes.slice(0, 24)), /incomplete/);
  const corrupt = image.bytes.slice(); corrupt[45] ^= 1; assert.throws(() => inspectLogo(corrupt), /damaged/);
  assert.throws(() => inspectLogo(new Uint8Array(2_000_001)), /2 MB/);
  const largeDimensions = image.bytes.slice(); new DataView(largeDimensions.buffer).setUint32(16, 5000); assert.throws(() => inspectLogo(largeDimensions), /4096/);
});

test('document PDF: all three templates, both page sizes, accented text and pricing rules survive real pdfme generation', async () => {
  const assets = { fontBytes: new Uint8Array(await readFile('public/tools-assets/fonts/NotoSans-Regular.ttf')) };
  const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs');
  for (const original of Object.values(documentExamples)) for (const paper of ['A4', 'Letter'] as const) {
    const data = structuredClone(original); data.paper = paper; data.business.name = 'Services Boréal — café, qualité & rénovation';
    if (data.templateId === 'quotation') data.logo = paper === 'A4' ? tinyPng : tinyJpeg;
    const bytes = await generateDocumentPdf(data, assets); assert.equal(new TextDecoder().decode(bytes.slice(0, 5)), '%PDF-');
    const pdf = await getDocument({ data: bytes.slice(), useSystemFonts: false, useWasm: false }).promise;
    let fullText = '';
    for (let p = 1; p <= pdf.numPages; p++) { const page = await pdf.getPage(p); const content = await page.getTextContent(); fullText += content.items.map(item => 'str' in item ? item.str : '').join(' '); }
    assert.ok(fullText.includes('Services Boréal')); assert.ok(fullText.includes('rénovation')); assert.ok(fullText.includes(data.client.name));
    if (!data.pricing) for (const forbidden of ['Subtotal:', 'Discount:', 'TOTAL:', 'Unit price', 'CAD ']) assert.equal(fullText.includes(forbidden), false);
    const first = await pdf.getPage(1); const size = first.getViewport({ scale: 1 }); assert.ok(Math.abs(size.width - (paper === 'A4' ? 595.28 : 612)) < 1);
    await pdf.loadingTask.destroy();
  }
});
test('document PDF: unsupported glyphs explain the failure while JSON preserves the original draft', async () => {
  const data = quote(); data.business.name = 'Business 漢字 🦄';
  assert.equal(validateDocument(data).ok, true);
  assert.equal(JSON.parse(JSON.stringify(data)).business.name, data.business.name);
  const assets = { fontBytes: new Uint8Array(await readFile('public/tools-assets/fonts/NotoSans-Regular.ttf')) };
  await assert.rejects(() => generateDocumentPdf(data, assets), /PDF font cannot display.*save JSON/);
});
test('document PDF: 60 long line items and French notes paginate without losing the final item or notes', async () => {
  const data = quote(); data.business.name = 'Atelier Boréal — services aux entreprises et entretien des espaces de travail';
  data.client.name = 'La Société des espaces collaboratifs — bureau de Montréal';
  data.items = Array.from({ length: 60 }, (_, i) => ({ id: `long-${i}`, description: `Repère ${String(i + 1).padStart(2, '0')} : Vérification détaillée de l’équipement, contrôle de qualité et préparation du matériel pour l’équipe. ${'Les étapes et les résultats sont documentés pour le suivi. '.repeat(3)}`, quantity: '2.5', unitPrice: '199.95' }));
  data.notes = `${'Résumé des travaux : l’équipe a vérifié les systèmes et préparé les recommandations. '.repeat(45)}FIN-DES-NOTES`;
  const assets = { fontBytes: new Uint8Array(await readFile('public/tools-assets/fonts/NotoSans-Regular.ttf')) };
  const bytes = await generateDocumentPdf(data, assets);
  const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs'); const pdf = await getDocument({ data: bytes.slice(), useSystemFonts: false, useWasm: false }).promise;
  assert.ok(pdf.numPages >= 5, `Expected multipage PDF, got ${pdf.numPages}`);
  let text = ''; for (let p = 1; p <= pdf.numPages; p++) { const page = await pdf.getPage(p); const content = await page.getTextContent(); text += content.items.map(item => 'str' in item ? item.str : '').join(' '); }
  for (let i = 1; i <= 60; i++) assert.ok(text.includes(`Repère ${String(i).padStart(2, '0')}`), `Missing item ${i}`);
  assert.ok(text.includes('FIN-DES-NOTES')); assert.ok(text.includes('29,992.80'));
  if (process.env.ESSENTIAL_TEST_ARTIFACTS === '1') { await mkdir('output/tools/document', { recursive: true }); await writeFile('output/tools/document/long-french-quotation.pdf', bytes); }
  await pdf.loadingTask.destroy();
});
