import { Buffer } from "node:buffer";
import yauzl from "yauzl";
import { diagnostic, emptyResult, oversizeDiagnostic, shouldStop } from "./_internal.js";
const PARSER_ID = "docx";
const PARSER_VERSION = "1";
const DOCX_MEDIA = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const DOCUMENT_XML_ENTRY = "word/document.xml";
const MAX_DOCUMENT_XML_INFLATED_BYTES = 16 * 1024 * 1024;
const MAX_DOCUMENT_XML_INFLATE_RATIO = 100;
const HEADING_STYLE_PATTERN = /<w:pStyle\b[^>]*w:val="Heading([1-6])"/i;
const PARAGRAPH_PATTERN = /<w:p\b[\s\S]*?<\/w:p>/gi;
const TEXT_RUN_PATTERN = /<w:t\b[^>]*>([\s\S]*?)<\/w:t>/gi;
function isDocx(input) {
    return input.extension.toLowerCase() === "docx" || input.mediaType.toLowerCase() === DOCX_MEDIA;
}
function cancelled(capability, input, options) {
    return emptyResult(capability, input.documentId, options, [
        diagnostic("PARSER_CANCELLED", "caller aborted parser", input.documentId, "info"),
    ]);
}
function syncFallback(capability) {
    return (input, options) => {
        return emptyResult(capability, input.documentId, options, [
            diagnostic("UNSUPPORTED_FORMAT", "docx parser requires async caller; use parseAsync via discovery", input.documentId, "info"),
        ], [unsupportedMediaUnit(input.documentId, "docx-async-required")]);
    };
}
function toError(error, fallback) {
    return error instanceof Error ? error : new Error(fallback);
}
function closeZipQuietly(zip) {
    try {
        zip.close();
    }
    catch {
        // Close failures are non-fatal during parser cleanup.
    }
}
function openZip(bytes) {
    return new Promise((resolve, reject) => {
        yauzl.fromBuffer(Buffer.from(bytes), { lazyEntries: true, decodeStrings: true }, (error, zip) => {
            if (error !== null) {
                reject(toError(error, "failed to open docx zip"));
                return;
            }
            resolve(zip);
        });
    });
}
function maxInflatedEntryBytes(maxInputBytes) {
    const inputCap = Math.max(1, Math.floor(maxInputBytes));
    return Math.min(MAX_DOCUMENT_XML_INFLATED_BYTES, inputCap * 10);
}
function assertEntryWithinLimits(entry, limits) {
    if (entry.uncompressedSize > limits.maxInflatedEntryBytes) {
        throw new Error(`docx document.xml inflated size ${String(entry.uncompressedSize)} exceeds limit ${String(limits.maxInflatedEntryBytes)}`);
    }
    if (entry.compressedSize > 0 &&
        entry.uncompressedSize / entry.compressedSize > limits.maxInflateRatio) {
        throw new Error("docx document.xml compression ratio exceeds limit");
    }
}
function destroyStream(readStream, error) {
    const destroy = readStream.destroy;
    if (typeof destroy === "function") {
        destroy.call(readStream, error);
    }
}
function readEntryText(zip, entry, limits) {
    assertEntryWithinLimits(entry, limits);
    return new Promise((resolve, reject) => {
        zip.openReadStream(entry, (error, stream) => {
            if (error !== null) {
                reject(toError(error, "failed to open docx entry stream"));
                return;
            }
            const readStream = stream;
            const chunks = [];
            let inflatedBytes = 0;
            let settled = false;
            const rejectOnce = (streamError) => {
                if (settled) {
                    return;
                }
                settled = true;
                reject(streamError);
                destroyStream(readStream, streamError);
            };
            readStream.on("data", (chunk) => {
                if (settled) {
                    return;
                }
                inflatedBytes += chunk.byteLength;
                if (inflatedBytes > limits.maxInflatedEntryBytes) {
                    rejectOnce(new Error("docx document.xml inflated stream exceeds limit"));
                    return;
                }
                chunks.push(chunk);
            });
            readStream.on("end", () => {
                if (settled) {
                    return;
                }
                settled = true;
                resolve(Buffer.concat(chunks).toString("utf8"));
            });
            readStream.on("error", (streamError) => {
                rejectOnce(streamError);
            });
        });
    });
}
function readDocumentXmlFromZip(zip, limits) {
    return new Promise((resolve, reject) => {
        let settled = false;
        const resolveOnce = (value) => {
            if (settled) {
                return;
            }
            settled = true;
            zip.removeListener("entry", onEntry);
            zip.removeListener("end", onEnd);
            zip.removeListener("error", onError);
            resolve(value);
        };
        const rejectOnce = (error) => {
            if (settled) {
                return;
            }
            settled = true;
            zip.removeListener("entry", onEntry);
            zip.removeListener("end", onEnd);
            zip.removeListener("error", onError);
            reject(error);
        };
        const onEnd = () => {
            rejectOnce(new Error("docx missing word/document.xml"));
        };
        const onError = (error) => {
            rejectOnce(toError(error, "failed to read docx zip"));
        };
        const handleEntry = async (entry) => {
            if (entry.fileName !== DOCUMENT_XML_ENTRY) {
                zip.readEntry();
                return;
            }
            try {
                const xml = await readEntryText(zip, entry, limits);
                resolveOnce(xml);
            }
            catch (error) {
                rejectOnce(toError(error, "failed to read docx entry"));
            }
        };
        const onEntry = (entry) => {
            void handleEntry(entry);
        };
        zip.on("entry", onEntry);
        zip.once("end", onEnd);
        zip.once("error", onError);
        zip.readEntry();
    });
}
async function readDocumentXml(bytes, maxInputBytes) {
    const zip = (await openZip(bytes));
    try {
        return await readDocumentXmlFromZip(zip, {
            maxInflatedEntryBytes: maxInflatedEntryBytes(maxInputBytes),
            maxInflateRatio: MAX_DOCUMENT_XML_INFLATE_RATIO,
        });
    }
    finally {
        closeZipQuietly(zip);
    }
}
function decodeXmlEntities(value) {
    return value
        .replaceAll("&lt;", "<")
        .replaceAll("&gt;", ">")
        .replaceAll("&quot;", '"')
        .replaceAll("&apos;", "'")
        .replaceAll("&amp;", "&");
}
function headingLevelOf(paragraphXml) {
    const match = HEADING_STYLE_PATTERN.exec(paragraphXml);
    return match?.[1] === undefined ? undefined : Number(match[1]);
}
function paragraphText(paragraphXml) {
    const withBreaks = paragraphXml
        .replaceAll(/<w:tab\s*\/>/gi, "\t")
        .replaceAll(/<w:br\s*\/>/gi, "\n")
        .replaceAll(/<w:cr\s*\/>/gi, "\n");
    const parts = Array.from(withBreaks.matchAll(TEXT_RUN_PATTERN))
        .map((match) => decodeXmlEntities(match[1] ?? ""))
        .filter((part) => part.length > 0);
    return parts.join("").trim();
}
function parseParagraphs(xml) {
    const out = [];
    for (const match of xml.matchAll(PARAGRAPH_PATTERN)) {
        const paragraphXml = match[0];
        const text = paragraphText(paragraphXml);
        if (text.length === 0)
            continue;
        const headingLevel = headingLevelOf(paragraphXml);
        out.push(headingLevel === undefined ? { text } : { text, headingLevel });
    }
    return out;
}
function paragraphStarts(paragraphs) {
    const starts = [];
    let cursor = 0;
    for (const paragraph of paragraphs) {
        starts.push(cursor);
        cursor += paragraph.text.length + 1;
    }
    return { starts, end: Math.max(0, cursor - 1) };
}
function isHeadingEntry(entry) {
    return entry.paragraph.headingLevel !== undefined;
}
function collectHeadings(paragraphs) {
    return paragraphs.map((paragraph, index) => ({ paragraph, index })).filter(isHeadingEntry);
}
function unsupportedMediaUnit(documentId, reason) {
    return { kind: "unsupported-media", documentId, reason };
}
function sectionUnit(section) {
    return {
        kind: "section",
        documentId: section.documentId,
        sectionPath: section.sectionPath,
        characterStart: section.characterStart,
        characterEnd: section.characterEnd,
    };
}
function unsectionedRecords(input, end) {
    const sectionRecord = {
        documentId: input.documentId,
        sectionPath: [],
        characterStart: 0,
        characterEnd: end,
    };
    return {
        sections: [sectionRecord],
        units: [sectionUnit(sectionRecord)],
    };
}
function appendSectionRecord(sections, units, input, sectionPath, start, end) {
    const sectionRecord = {
        documentId: input.documentId,
        sectionPath,
        characterStart: start,
        characterEnd: end,
    };
    sections.push(sectionRecord);
    units.push(sectionUnit(sectionRecord));
}
function appendLeadingPreambleSection(sections, units, input, headings, offsets) {
    const firstHeading = headings[0];
    if (firstHeading === undefined) {
        return;
    }
    const firstHeadingStart = offsets.starts[firstHeading.index] ?? 0;
    if (firstHeadingStart > 0) {
        appendSectionRecord(sections, units, input, [], 0, firstHeadingStart);
    }
}
function buildSections(paragraphs, input) {
    const stack = [];
    const sections = [];
    const units = [];
    const offsets = paragraphStarts(paragraphs);
    const headings = collectHeadings(paragraphs);
    if (headings.length === 0) {
        return unsectionedRecords(input, offsets.end);
    }
    appendLeadingPreambleSection(sections, units, input, headings, offsets);
    for (let i = 0; i < headings.length; i += 1) {
        const current = headings[i];
        if (current === undefined) {
            continue;
        }
        const next = headings[i + 1];
        const level = current.paragraph.headingLevel;
        while (stack.length >= level)
            stack.pop();
        stack.push(current.paragraph.text);
        const start = offsets.starts[current.index] ?? 0;
        const end = next === undefined ? offsets.end : (offsets.starts[next.index] ?? offsets.end);
        appendSectionRecord(sections, units, input, [...stack], start, end);
    }
    return { sections, units };
}
function docxNoTextResult(capability, input, options) {
    return emptyResult(capability, input.documentId, options, [
        diagnostic("UNSUPPORTED_FORMAT", "docx has no extractable text content", input.documentId, "info"),
    ], [unsupportedMediaUnit(input.documentId, "docx-no-text")]);
}
function docxParseResult(capability, input, options, paragraphs) {
    const { sections, units } = buildSections(paragraphs, input);
    const normalizedText = paragraphs.map((paragraph) => paragraph.text).join("\n");
    return {
        documentId: input.documentId,
        parser: { parserId: capability.parserId, parserVersion: capability.parserVersion },
        pages: [],
        sections,
        units,
        diagnostics: [],
        extractedAt: options.now(),
        normalizedText,
    };
}
async function asyncParse(capability, input, options) {
    if (input.bytes.byteLength > options.maxBytes) {
        return emptyResult(capability, input.documentId, options, [
            oversizeDiagnostic(input.documentId, input.bytes.byteLength, options.maxBytes),
        ]);
    }
    if (options.signal?.aborted === true) {
        return cancelled(capability, input, options);
    }
    const startedAt = options.now();
    try {
        const xml = await readDocumentXml(input.bytes, options.maxBytes);
        const limit = shouldStop(startedAt, options, 0);
        if (limit.stop && limit.code !== undefined && limit.message !== undefined) {
            return emptyResult(capability, input.documentId, options, [
                diagnostic(limit.code, limit.message, input.documentId, "info"),
            ]);
        }
        const paragraphs = parseParagraphs(xml);
        if (paragraphs.length === 0) {
            return docxNoTextResult(capability, input, options);
        }
        return docxParseResult(capability, input, options, paragraphs);
    }
    catch (error) {
        return emptyResult(capability, input.documentId, options, [
            diagnostic("MALFORMED_INPUT", error instanceof Error ? error.message : "failed to parse docx", input.documentId, "error"),
        ]);
    }
}
const capability = Object.freeze({
    parserId: PARSER_ID,
    parserVersion: PARSER_VERSION,
    matches: isDocx,
});
export const docxParser = Object.freeze({
    capability,
    parse: syncFallback(capability),
    parseAsync: (input, options) => asyncParse(capability, input, options),
});
