import { T as assertSafeSlug } from "./session-store-DWxJ5Pof.js";
import { t as logger } from "./logger-UaF5p9d1.js";
import { chunkText } from "./chunk-e_w8qqtP.js";
import { n as htmlToMarkdown, t as htmlConverter } from "./html-BaeOCZKE.js";
import path from "path";
import fs from "fs";
//#region src/sync/converters/text.ts
/** Escape a CSV cell for safe inclusion in a Markdown table cell. */
function mdCell(value) {
	return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim();
}
/**
* Minimal RFC-4180-ish CSV line splitter: handles quoted fields containing
* commas and escaped double quotes. Good enough for rendering CSV attachments
* as readable Markdown tables (we are not round-tripping data).
*/
function parseCsvLine(line) {
	const out = [];
	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 === ",") {
			out.push(cur);
			cur = "";
		} else cur += ch;
	}
	out.push(cur);
	return out;
}
/** Render a matrix of cells as a GitHub-flavored Markdown pipe table. */
function rowsToMarkdown(rows) {
	if (rows.length === 0) return "";
	const width = Math.max(...rows.map((r) => r.length));
	const pad = (r) => Array.from({ length: width }, (_, i) => mdCell(r[i] ?? ""));
	const header = pad(rows[0] ?? []);
	return [
		`| ${header.join(" | ")} |`,
		`| ${header.map(() => "---").join(" | ")} |`,
		...rows.slice(1).map((r) => `| ${pad(r).join(" | ")} |`)
	].join("\n");
}
/** Render CSV text as a GitHub-flavored Markdown pipe table. */
function csvToMarkdown(csv) {
	return rowsToMarkdown(csv.split(/\r?\n/).filter((l) => l.trim().length > 0).map(parseCsvLine));
}
const TEXT_EXTENSIONS = [
	"txt",
	"text",
	"log",
	"md",
	"markdown"
];
const CODE_FENCE_EXTENSIONS = {
	json: "json",
	xml: "xml",
	yaml: "yaml",
	yml: "yaml"
};
/**
* Converter for plain-text-ish attachments: Markdown/text passthrough, CSV/TSV
* to Markdown tables, and structured text (JSON/XML/YAML) into fenced code
* blocks so they stay readable and searchable without a heavy parser.
*/
const textConverter = {
	name: "text",
	extensions: [
		...TEXT_EXTENSIONS,
		"csv",
		"tsv",
		...Object.keys(CODE_FENCE_EXTENSIONS)
	],
	mimeTypes: [
		"text/plain",
		"text/csv",
		"text/markdown",
		"application/json",
		"text/*"
	],
	convert(buffer, filename) {
		const ext = filename.split(".").pop()?.toLowerCase() ?? "";
		const content = buffer.toString("utf-8");
		if (ext === "csv") return Promise.resolve({
			markdown: csvToMarkdown(content),
			meta: { format: "csv" }
		});
		if (ext === "tsv") {
			const asCsv = content.replace(/\t/g, ",");
			return Promise.resolve({
				markdown: csvToMarkdown(asCsv),
				meta: { format: "tsv" }
			});
		}
		const fence = CODE_FENCE_EXTENSIONS[ext];
		if (fence) return Promise.resolve({
			markdown: `\`\`\`${fence}\n${content.trim()}\n\`\`\``,
			meta: { format: fence }
		});
		return Promise.resolve({
			markdown: content.trim(),
			meta: { format: "text" }
		});
	}
};
//#endregion
//#region src/sync/converters/docx.ts
/**
* DOCX → Markdown via mammoth (DOCX → semantic HTML) then Turndown (HTML →
* Markdown). Mammoth's own Markdown output is deprecated; the HTML route keeps
* tables, lists and headings intact. mammoth is loaded lazily.
*/
const docxConverter = {
	name: "docx",
	extensions: ["docx"],
	mimeTypes: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
	async convert(buffer) {
		const { value: html, messages } = await (await import("mammoth")).default.convertToHtml({ buffer });
		return {
			markdown: await htmlToMarkdown(html),
			meta: {
				format: "docx",
				warnings: messages.filter((m) => m.type === "warning").length
			}
		};
	}
};
//#endregion
//#region src/sync/converters/xlsx.ts
/** Render a single ExcelJS cell value as plain text. */
function cellText(value) {
	if (value === null || value === void 0) return "";
	if (value instanceof Date) return value.toISOString().slice(0, 10);
	if (typeof value === "object") {
		const v = value;
		if (typeof v["text"] === "string") return v["text"];
		if ("result" in v) return String(v["result"] ?? "");
		if (Array.isArray(v["richText"])) return v["richText"].map((r) => r.text ?? "").join("");
		if ("hyperlink" in v) return String(v["text"] ?? v["hyperlink"] ?? "");
	}
	return String(value);
}
/**
* Spreadsheet (XLSX) → Markdown via ExcelJS. Each worksheet becomes a
* `## <sheet name>` section followed by a GitHub-flavored Markdown table.
* ExcelJS is loaded lazily.
*/
const xlsxConverter = {
	name: "xlsx",
	extensions: ["xlsx", "xlsm"],
	mimeTypes: ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel"],
	async convert(buffer) {
		const wb = new (await (import("exceljs"))).default.Workbook();
		await wb.xlsx.load(buffer);
		const sections = [];
		const sheetNames = [];
		wb.eachSheet((sheet) => {
			sheetNames.push(sheet.name);
			const rows = [];
			sheet.eachRow({ includeEmpty: false }, (row) => {
				const cells = [];
				row.eachCell({ includeEmpty: true }, (cell) => {
					cells.push(cellText(cell.value));
				});
				rows.push(cells);
			});
			const table = rowsToMarkdown(rows);
			if (table) sections.push(`## ${sheet.name}\n\n${table}`);
		});
		return {
			markdown: sections.join("\n\n"),
			meta: {
				format: "xlsx",
				sheets: sheetNames
			}
		};
	}
};
//#endregion
//#region src/sync/converters/pptx.ts
/** Extract the visible text runs (`<a:t>…</a:t>`) from one slide's XML. */
function extractSlideText(xml) {
	return [...xml.matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map((m) => (m[1] ?? "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'")).join(" ").replace(/\s+/g, " ").trim();
}
function slideNumber(entryName) {
	const m = entryName.match(/slide(\d+)\.xml$/);
	return m ? parseInt(m[1] ?? "0", 10) : 0;
}
/**
* PPTX → Markdown. A .pptx is a zip; slide text lives in `ppt/slides/slideN.xml`
* as `<a:t>` runs. We unzip with adm-zip (already a dependency) and emit one
* `## Slide N` section per slide — no extra native parser needed.
*/
const pptxConverter = {
	name: "pptx",
	extensions: ["pptx"],
	mimeTypes: ["application/vnd.openxmlformats-officedocument.presentationml.presentation"],
	async convert(buffer) {
		const AdmZip = (await import("adm-zip")).default;
		const slides = new AdmZip(buffer).getEntries().filter((e) => /^ppt\/slides\/slide\d+\.xml$/.test(e.entryName)).sort((a, b) => slideNumber(a.entryName) - slideNumber(b.entryName));
		const sections = [];
		for (const entry of slides) {
			const text = extractSlideText(entry.getData().toString("utf-8"));
			if (text) sections.push(`## Slide ${slideNumber(entry.entryName)}\n\n${text}`);
		}
		return {
			markdown: sections.join("\n\n"),
			meta: {
				format: "pptx",
				slides: slides.length
			}
		};
	}
};
//#endregion
//#region src/sync/converters/pdf.ts
/** Whether scanned-PDF OCR is enabled (opt-in, requires @napi-rs/canvas). */
function isPdfOcrEnabled() {
	const v = (process.env["DXCRM_PDF_OCR"] ?? "").toLowerCase();
	return v === "1" || v === "true" || v === "yes" || v === "on";
}
/**
* OCR a scanned PDF by rendering each page to a PNG (unpdf + @napi-rs/canvas)
* and running tesseract.js over it. The canvas backend is an optional peer
* dependency; if it isn't installed we throw a clear, catchable error so the
* caller can fall back to the OCR-candidate stub. Page count is capped via
* DXCRM_PDF_OCR_MAX_PAGES (default 20) to keep this slow path bounded.
*/
async function ocrPdf(pdf, totalPages) {
	try {
		await import("@napi-rs/canvas");
	} catch {
		throw new Error("PDF OCR requires the optional '@napi-rs/canvas' package — run `npm install @napi-rs/canvas`");
	}
	const { renderPageAsImage } = await import("unpdf");
	const { recognize } = await import("tesseract.js");
	const lang = process.env["DXCRM_OCR_LANG"] ?? "eng";
	const canvasImport = () => import("@napi-rs/canvas");
	const maxPages = Number(process.env["DXCRM_PDF_OCR_MAX_PAGES"] ?? 20) || 20;
	const pageCount = Math.min(totalPages, maxPages);
	const pages = [];
	for (let p = 1; p <= pageCount; p++) {
		const png = await renderPageAsImage(pdf, p, {
			canvasImport,
			scale: 2
		});
		const { data: { text } } = await recognize(Buffer.from(png), lang);
		if (text.trim()) pages.push(`## Page ${p}\n\n${text.trim()}`);
	}
	return pages.join("\n\n");
}
/**
* PDF → Markdown. Extracts the digital text layer with unpdf (a serverless
* pdf.js build). Scanned PDFs have no text layer: when DXCRM_PDF_OCR is enabled
* they are rendered and OCR'd page-by-page, otherwise they're flagged as OCR
* candidates rather than emitting garbage. unpdf is loaded lazily.
*/
const pdfConverter = {
	name: "pdf",
	extensions: ["pdf"],
	mimeTypes: ["application/pdf"],
	async convert(buffer, filename) {
		const { extractText, getDocumentProxy } = await import("unpdf");
		const pdf = await getDocumentProxy(new Uint8Array(buffer));
		const { totalPages, text } = await extractText(pdf, { mergePages: true });
		const merged = (Array.isArray(text) ? text.join("\n\n") : text).trim();
		if (merged) return {
			markdown: merged,
			meta: {
				format: "pdf",
				pages: totalPages
			}
		};
		if (isPdfOcrEnabled()) try {
			const ocr = await ocrPdf(pdf, totalPages);
			if (ocr.trim()) return {
				markdown: `> _OCR of \`${filename}\` (${totalPages} pages):_\n\n${ocr.trim()}`,
				meta: {
					format: "pdf",
					pages: totalPages,
					ocr: true
				}
			};
		} catch (err) {
			process.stderr.write(`[converters] pdf OCR failed for ${filename}: ${err.message}\n`);
		}
		return {
			markdown: "",
			meta: {
				format: "pdf",
				pages: totalPages,
				ocrCandidate: true
			}
		};
	}
};
//#endregion
//#region src/sync/converters/image.ts
/**
* Image → Markdown via Tesseract.js OCR (pure-JS, 100+ languages, fully local).
* This is the heaviest converter: tesseract.js downloads a WASM core and
* language data on first use, so it is loaded lazily and only invoked for image
* attachments. Language defaults to English, override with DXCRM_OCR_LANG.
*/
const imageConverter = {
	name: "image",
	extensions: [
		"png",
		"jpg",
		"jpeg",
		"tif",
		"tiff",
		"bmp",
		"webp",
		"gif",
		"pbm"
	],
	mimeTypes: ["image/*"],
	async convert(buffer, filename) {
		const lang = process.env["DXCRM_OCR_LANG"] ?? "eng";
		const { recognize } = await import("tesseract.js");
		const { data: { text } } = await recognize(buffer, lang);
		const ocr = text.trim();
		return {
			markdown: ocr ? `> _OCR of \`${filename}\`:_\n\n${ocr}` : "",
			meta: {
				format: "image",
				ocr: true,
				lang
			}
		};
	}
};
//#endregion
//#region src/sync/converters/registry.ts
/**
* Fallback converter for unknown/binary attachments: emit a small metadata stub
* instead of garbage bytes, so the attachment is still recorded and linkable.
*/
const fallbackConverter = {
	name: "binary",
	extensions: [],
	convert(buffer, filename) {
		const kb = Math.max(1, Math.round(buffer.length / 1024));
		return Promise.resolve({
			markdown: `> _Binary attachment \`${filename}\` (${kb} KB) — no text representation available._`,
			meta: {
				format: "binary",
				bytes: buffer.length
			}
		});
	}
};
/**
* Ordered converter registry. Earlier entries win on extension conflicts. The
* text converter is intentionally last among the "real" converters so that more
* specific formats (html, etc.) take precedence over generic text matching.
*/
const CONVERTERS = [
	docxConverter,
	xlsxConverter,
	pptxConverter,
	pdfConverter,
	imageConverter,
	htmlConverter,
	textConverter
];
function extensionOf(filename) {
	return filename.split(".").pop()?.toLowerCase() ?? "";
}
function mimeMatches(converter, mime) {
	if (!converter.mimeTypes) return false;
	const lower = mime.toLowerCase();
	return converter.mimeTypes.some((m) => {
		const ml = m.toLowerCase();
		if (ml.endsWith("/*")) return lower.startsWith(ml.slice(0, -1));
		return ml === lower;
	});
}
/**
* Pick the converter for an attachment by file extension first (most reliable
* for Gmail attachments, which always carry a filename), then by MIME type.
* Returns `undefined` when nothing matches.
*/
function matchConverter(filename, mime) {
	const ext = extensionOf(filename);
	if (ext) {
		const byExt = CONVERTERS.find((c) => c.extensions.includes(ext));
		if (byExt) return byExt;
	}
	if (mime) {
		const byMime = CONVERTERS.find((c) => mimeMatches(c, mime));
		if (byMime) return byMime;
	}
}
/**
* Convert an attachment to Markdown, dispatching to the best converter and
* falling back to a metadata stub. Converter errors never throw: they are
* swallowed into the fallback so a single bad attachment can't break a sync.
*/
async function convertAttachment(buffer, filename, mime) {
	const converter = matchConverter(filename, mime) ?? fallbackConverter;
	try {
		const result = await converter.convert(buffer, filename);
		if (!result.markdown.trim()) return {
			markdown: `> _Attachment \`${filename}\` contained no extractable text._`,
			meta: {
				...result.meta,
				empty: true
			}
		};
		return result;
	} catch (err) {
		process.stderr.write(`[converters] ${converter.name} failed for ${filename}: ${err.message}\n`);
		return fallbackConverter.convert(buffer, filename);
	}
}
/**
* Recursively collect downloadable attachment parts from a Gmail message
* payload — any MIME part that carries both a filename and a body.attachmentId.
* Inline parts without a filename (e.g. signature logos) are ignored.
*/
function collectAttachmentParts(payload) {
	const out = [];
	const walk = (part) => {
		if (!part) return;
		const filename = part.filename ?? "";
		const attachmentId = part.body?.attachmentId ?? "";
		if (filename && attachmentId) out.push({
			filename,
			mimeType: part.mimeType ?? "application/octet-stream",
			attachmentId,
			size: part.body?.size ?? 0
		});
		for (const child of part.parts ?? []) walk(child);
	};
	walk(payload);
	return out;
}
/** Make a filename safe for use as a single path segment. */
function sanitizeFilename(name) {
	return (name.split(/[\\/]/).pop() ?? name).replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 120) || "attachment";
}
/**
* Persist one already-downloaded attachment: save the raw bytes under
* `customers/<slug>/attachments/<messageId>__<name>`, convert to a sibling
* `.md` file, and chunk-index the Markdown into LanceDB for semantic search.
* Provider-agnostic — used by every mailbox connector (Gmail, IMAP, Graph).
*/
async function persistAttachment(opts) {
	assertSafeSlug(opts.slug);
	const attachmentsDir = path.join(opts.dataDir, "customers", opts.slug, "attachments");
	fs.mkdirSync(attachmentsDir, { recursive: true });
	const storedName = `${opts.messageId}__${sanitizeFilename(opts.filename)}`;
	const markdownName = `${storedName}.md`;
	fs.writeFileSync(path.join(attachmentsDir, storedName), opts.buffer);
	const { markdown } = await convertAttachment(opts.buffer, opts.filename, opts.mimeType);
	const mdBody = `# ${opts.filename}\n\n_Source: ${opts.source} · ${opts.date}_\n\n${markdown}\n`;
	fs.writeFileSync(path.join(attachmentsDir, markdownName), mdBody);
	const ref = `${opts.source}#att:${opts.filename}`;
	const { indexInLanceDB } = await import("./mcp.js").then((n) => n.t);
	const chunks = chunkText(markdown);
	for (let i = 0; i < chunks.length; i++) await indexInLanceDB(opts.dataDir, opts.slug, chunks[i], `${ref}#${i}`, {
		date: opts.date,
		type: "attachment"
	}).catch((err) => {
		logger.error("attachments", "attachment index failed", { error: err.message });
	});
	return {
		originalName: opts.filename,
		storedName,
		markdownName,
		ref,
		chunks: chunks.length
	};
}
/**
* Download, convert and index every attachment of a single Gmail message.
* Fetches each part's bytes from the Gmail API, then delegates persistence to
* the shared {@link persistAttachment}. Failures on a single attachment are
* logged and skipped — they never abort the message sync.
*/
async function processMessageAttachments(opts) {
	const parts = collectAttachmentParts(opts.payload);
	if (parts.length === 0) return [];
	assertSafeSlug(opts.slug);
	const maxBytes = opts.maxBytes ?? 26214400;
	const saved = [];
	for (const part of parts) try {
		if (part.size > maxBytes) {
			logger.warn("gmail-sync", "skipping oversized attachment", {
				filename: part.filename,
				bytes: part.size
			});
			continue;
		}
		const data = (await opts.gmail.users.messages.attachments.get({
			userId: "me",
			messageId: opts.messageId,
			id: part.attachmentId
		})).data.data;
		if (!data) continue;
		saved.push(await persistAttachment({
			dataDir: opts.dataDir,
			slug: opts.slug,
			messageId: opts.messageId,
			source: opts.source,
			date: opts.date,
			filename: part.filename,
			mimeType: part.mimeType,
			buffer: Buffer.from(data, "base64url")
		}));
	} catch (err) {
		logger.warn("gmail-sync", "attachment failed", {
			filename: part.filename,
			error: err.message
		});
	}
	return saved;
}
//#endregion
export { processMessageAttachments };

//# sourceMappingURL=attachments-Co3kXIvu.js.map