import { createHash } from 'node:crypto'; interface ZipEntry { name: string; data: Buffer; } interface ReportWordInput { code: string; title: string; generatedAt: Date; frozenSha256: string; frozenSnapshot: Record; } const crcTable = (() => { const table = new Uint32Array(256); for (let n = 0; n < 256; n += 1) { let value = n; for (let k = 0; k < 8; k += 1) { value = (value & 1) ? 0xedb88320 ^ (value >>> 1) : value >>> 1; } table[n] = value >>> 0; } return table; })(); function crc32(data: Buffer): number { let crc = 0xffffffff; for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; } function xmlEscape(value: unknown): string { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function asArray(value: unknown): Array> { return Array.isArray(value) ? value.map(asRecord) : []; } function text(value: unknown, fallback = '—'): string { const normalized = String(value ?? '').trim(); return normalized || fallback; } function isoDate(value: unknown): string { const date = new Date(String(value ?? '')); return Number.isFinite(date.getTime()) ? date.toLocaleDateString('es-AR') : '—'; } function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string { const styleXml = style ? `` : ''; return `${styleXml}${xmlEscape(value)}`; } function labelValue(label: string, value: string): string { return `${xmlEscape(label)}: ${xmlEscape(value)}`; } function table(headers: string[], rows: string[][]): string { const cell = (value: string, bold = false) => `${bold ? '' : ''}${xmlEscape(value)}`; const header = `${headers.map((item) => cell(item, true)).join('')}`; const body = rows.map((row) => `${row.map((item) => cell(item)).join('')}`).join(''); return `${header}${body}`; } function documentXml(input: ReportWordInput): string { const report = asRecord(input.frozenSnapshot.report); const actClosure = asRecord(input.frozenSnapshot.actClosure); const prepared = asRecord(actClosure.preparedSnapshot); const act = asRecord(prepared.act); const visit = asRecord(act.visit); const responsible = asRecord(prepared.responsible); const team = asArray(prepared.team); const assets = asArray(prepared.assets); const findings = asArray(prepared.findings); const signatures = asArray(actClosure.signatures); const companies = [...new Set(assets.map((item) => text(asRecord(item.operatorCompany).name, '')).filter(Boolean))]; const areas = [...new Set(assets.map((item) => text(asRecord(item.operationalArea).name, '')).filter(Boolean))]; const inspectorNames = team.map((member) => `${text(member.firstName, '')} ${text(member.lastName, '')}`.trim()).filter(Boolean); const assetRows = assets.map((item) => [text(item.code), text(item.name), text(item.commonName, ''), text(item.typeName)]); const findingRows = findings.map((item) => [text(item.code), text(item.title), text(item.description), item.severity == null ? '—' : `${text(item.severity)}/10`, isoDate(item.correctionDueOn)]); const signatureRows = signatures.map((item) => [text(item.signerType), text(item.signerName), text(item.status), isoDate(item.signedAt ?? item.createdAt)]); const body = [ paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'), paragraph('Borrador automático para revisión del Director de Hidrocarburos', 'Heading2'), labelValue('Informe', input.code), labelValue('Título', input.title), labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')), labelValue('Acta', text(report.actCode ?? act.code)), labelValue('Inspección', text(report.visitCode ?? visit.code)), labelValue('Empresa', companies.join(' · ') || 'Según alcance del acta'), labelValue('Área', areas.join(' · ') || 'Según alcance del acta'), labelValue('Inspector/es', inspectorNames.join(' · ') || '—'), labelValue('Responsable de empresa', text(responsible.fullName)), labelValue('Cargo', text(responsible.position)), paragraph('Datos de la visita', 'Heading1'), labelValue('Objetivo', text(visit.objective)), labelValue('Fecha de inspección', isoDate(act.occurredAt)), labelValue('Resumen', text(act.summary)), labelValue('Observaciones', text(act.observations)), paragraph('Elementos inspeccionados', 'Heading1'), assetRows.length ? table(['Código', 'Nombre técnico', 'Nombre habitual', 'Tipo'], assetRows) : paragraph('No se registraron elementos en la instantánea.'), paragraph('Hallazgos', 'Heading1'), findingRows.length ? table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento'], findingRows) : paragraph('No se registraron hallazgos en la instantánea.'), paragraph('Firmas y constancias', 'Heading1'), signatureRows.length ? table(['Tipo', 'Firmante', 'Estado', 'Fecha'], signatureRows) : paragraph('No se registraron firmas en la instantánea.'), paragraph('Integridad documental', 'Heading1'), labelValue('Hash del informe', input.frozenSha256), labelValue('Hash de cierre del acta', text(report.actClosureSha256)), paragraph('El contenido de este archivo fue generado desde la instantánea congelada del acta. El diseño institucional y la firma final del Director se incorporarán en la etapa de revisión correspondiente.'), ].join(''); return `${body}`; } function buildZip(entries: ZipEntry[]): Buffer { const locals: Buffer[] = []; const centrals: Buffer[] = []; let offset = 0; const dosDate = 33; const dosTime = 0; for (const entry of entries) { const name = Buffer.from(entry.name, 'utf8'); const crc = crc32(entry.data); const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0, 6); local.writeUInt16LE(0, 8); local.writeUInt16LE(dosTime, 10); local.writeUInt16LE(dosDate, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(entry.data.length, 18); local.writeUInt32LE(entry.data.length, 22); local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28); locals.push(local, name, entry.data); const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0, 8); central.writeUInt16LE(0, 10); central.writeUInt16LE(dosTime, 12); central.writeUInt16LE(dosDate, 14); central.writeUInt32LE(crc, 16); central.writeUInt32LE(entry.data.length, 20); central.writeUInt32LE(entry.data.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt16LE(0, 30); central.writeUInt16LE(0, 32); central.writeUInt16LE(0, 34); central.writeUInt16LE(0, 36); central.writeUInt32LE(0, 38); central.writeUInt32LE(offset, 42); centrals.push(central, name); offset += local.length + name.length + entry.data.length; } const centralData = Buffer.concat(centrals); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(centralData.length, 12); end.writeUInt32LE(offset, 16); end.writeUInt16LE(0, 20); return Buffer.concat([...locals, centralData, end]); } export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } { const entries: ZipEntry[] = [ { name: '[Content_Types].xml', data: Buffer.from('', 'utf8'), }, { name: '_rels/.rels', data: Buffer.from('', 'utf8'), }, { name: 'word/_rels/document.xml.rels', data: Buffer.from('', 'utf8'), }, { name: 'word/styles.xml', data: Buffer.from('', 'utf8'), }, { name: 'word/document.xml', data: Buffer.from(documentXml(input), 'utf8') }, { name: 'docProps/core.xml', data: Buffer.from(`${xmlEscape(input.title)}DH InspecciónDH Inspección${input.generatedAt.toISOString()}`, 'utf8'), }, { name: 'docProps/app.xml', data: Buffer.from('DH Inspección', 'utf8'), }, ]; const buffer = buildZip(entries); return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; }