// ==========================================
// project-state.js: CCS project schema inside the PDF
// ==========================================
// Primary: Catalog /CCSProject dictionary
//   /Type  /CCSProject
//   /Ver   (4.0)
//   /Fmt   /GZipHex          — gzip(JSON) then hex-encoded in /Data
//   /Hash  (integrity of /Data — legacy recipe)
//   /Data  <hex of gzip…>
//
// Sealing (no infinite hash loop):
//   Hash *layers*, never "the final file that contains the hash":
//     originHash  = SHA-256 of bytes when the file was first opened this session
//     contentHash = SHA-256 of PDF *before* /CCSProject is attached
//     schemaHash  = SHA-256 of schema JSON *without* the .seal object
//   .seal is stored *inside* /Data; /Hash covers the gzip including .seal
//   (that's fine — seal fields don't depend on /Hash or the final file bytes).
//
// Flattened Save PDF: decorative overlays are baked in; schema still holds audit +
// form layout. Re-open skips re-spawning burned decor (flattenedExport).
//
// Legacy trailer still *readable* if present.

(function (global) {
    "use strict";

    const CCS_CATALOG_KEY = "CCSProject";
    const SCHEMA_VERSION = "4.0";
    const LEGACY_MARKER = "\n---CCS-SAVE-STATE---";
    const LEGACY_DELIM = "---HASH-DELIM---";

    function PDFLibNS() {
        return global.PDFLib || (typeof PDFLib !== "undefined" ? PDFLib : null);
    }

    function u8ToHex(u8) {
        const a = u8 instanceof Uint8Array ? u8 : new Uint8Array(u8);
        let s = "";
        for (let i = 0; i < a.length; i++) {
            s += a[i].toString(16).padStart(2, "0");
        }
        return s;
    }

    function hexToU8(hex) {
        const clean = String(hex || "").replace(/[^0-9a-fA-F]/g, "");
        if (clean.length % 2) throw new Error("Invalid hex length");
        const out = new Uint8Array(clean.length / 2);
        for (let i = 0; i < out.length; i++) {
            out[i] = parseInt(clean.substr(i * 2, 2), 16);
        }
        return out;
    }

    /** Real SHA-256 of raw bytes (for seals / origin). */
    async function sha256Hex(bytes) {
        const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
        const hashBuffer = await crypto.subtle.digest("SHA-256", u8);
        return Array.from(new Uint8Array(hashBuffer))
            .map((b) => b.toString(16).padStart(2, "0"))
            .join("");
    }

    /**
     * Integrity hash of gzip payload — legacy trailer recipe (do not change).
     */
    async function hashPayload(compressedU8) {
        if (typeof global.computeHash === "function") {
            return global.computeHash(compressedU8);
        }
        throw new Error("computeHash unavailable");
    }

    async function gzipJson(schemaObj) {
        const jsonStr = JSON.stringify(schemaObj);
        if (typeof global.compressData !== "function") {
            throw new Error("compressData unavailable");
        }
        return global.compressData(jsonStr);
    }

    async function gunzipToJson(compressedU8) {
        if (typeof global.decompressData !== "function") {
            throw new Error("decompressData unavailable");
        }
        const text = await global.decompressData(compressedU8);
        return JSON.parse(text);
    }

    /** Deterministic JSON for schemaHash (sorted keys). */
    function stableStringify(value) {
        if (value === null || typeof value !== "object") {
            return JSON.stringify(value);
        }
        if (Array.isArray(value)) {
            return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
        }
        const keys = Object.keys(value).sort();
        return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k])).join(",") + "}";
    }

    function stripSeal(schema) {
        if (!schema || typeof schema !== "object") return schema;
        const copy = Object.assign({}, schema);
        delete copy.seal;
        return copy;
    }

    function removeCcsProjectFromDoc(pdfDoc) {
        const PDF = PDFLibNS();
        if (!pdfDoc || !PDF) return;
        try {
            const key = PDF.PDFName.of(CCS_CATALOG_KEY);
            if (pdfDoc.catalog.has && pdfDoc.catalog.has(key)) {
                pdfDoc.catalog.delete(key);
            } else if (typeof pdfDoc.catalog.delete === "function") {
                pdfDoc.catalog.delete(key);
            }
        } catch (_) { /* ignore */ }
    }

    /**
     * Build .seal without creating a hash loop.
     * @param {object} schemaWithoutSeal
     * @param {string} contentHash  SHA-256 of PDF bytes *before* /CCSProject
     * @param {object} [extra]
     */
    async function buildSeal(schemaWithoutSeal, contentHash, extra) {
        const schemaHash = await sha256Hex(
            new TextEncoder().encode(stableStringify(schemaWithoutSeal))
        );
        const origin = (global.APP && global.APP.documentOrigin) || {};
        const seal = {
            v: 1,
            alg: "SHA-256",
            // PDF page/form bytes before attaching /CCSProject (not the final file)
            contentHash: contentHash || null,
            // Project/audit payload without this seal object
            schemaHash,
            // Bytes of the file when first opened this session (chain of custody)
            originHash: origin.hash || null,
            originAt: origin.at || null,
            sealedAt: new Date().toISOString(),
            purpose: (extra && extra.purpose) || schemaWithoutSeal.purpose || "project",
            flattened: !!(schemaWithoutSeal.flattenedExport),
            // Human-readable contract for verifiers / future crypto
            covers:
                "contentHash=PDF serialization before /CCSProject; " +
                "schemaHash=stable JSON of schema without .seal; " +
                "originHash=file bytes at open. " +
                "Final file hash is intentionally NOT stored (would be circular)."
        };
        // Device ECDSA signature over seal commitments (public JWK embedded in seal.sig)
        if (global.CcsProjectCrypto && typeof global.CcsProjectCrypto.signSeal === "function") {
            await global.CcsProjectCrypto.signSeal(seal);
        }
        return seal;
    }

    /**
     * Heuristics: how this PDF was likely produced (print-to-PDF, scan, office, CCS, …).
     * Uses Info dict Producer/Creator (often survives re-save better than private catalog keys).
     */
    function analyzePdfProvenance(meta) {
        const m = meta || {};
        const producer = String(m.producer || m.Producer || "").trim();
        const creator = String(m.creator || m.Creator || "").trim();
        const title = String(m.title || m.Title || "").trim();
        const keywords = String(m.keywords || m.Keywords || "").trim();
        const blob = (producer + " " + creator + " " + keywords).toLowerCase();

        const tags = [];
        let kind = "unknown";

        const isCcs = /ccs pdf editor|ccsproject|ccs-pdf-editor/i.test(blob)
            || /CCSProject/i.test(keywords);
        if (isCcs) {
            tags.push("ccs-editor");
            kind = "ccs-editor";
        }

        // Print-to-PDF / virtual printers
        const printRe = [
            /microsoft\s*print\s*to\s*pdf/i,
            /microsoft\s*pdf/i,
            /print\s*to\s*pdf/i,
            /pdf\s*printer/i,
            /adobe\s*pdf/i,
            /acrobat\s*distiller/i,
            /quartz\s*pdfcontext/i,
            /macos\s*quartz/i,
            /cups-pdf/i,
            /pdf24/i,
            /cutepdf/i,
            /foxit\s*pdf\s*printer/i,
            /nova\s*pdf/i,
            /doPDF/i,
            /bullzip/i,
            /primo\s*pdf/i,
            /chrome\s*pdf/i,
            /headlesschrome/i,
            /skia\/pdf/i,
            /edge\s*pdf/i
        ];
        if (printRe.some((re) => re.test(blob))) {
            tags.push("print-to-pdf");
            if (kind === "unknown") kind = "print-to-pdf";
        }

        // Scanners / imaging
        const scanRe = [
            /scan/i, /twain/i, /wia\b/i, /image\s*capture/i,
            /vuescan/i, /naps2/i, /epson/i, /canon\s*ij/i,
            /hp\s*scan/i, /fujitsu/i, /kodak/i, /abbyy/i,
            /imagemagick/i, /graphicsmagick/i, /sane\b/i
        ];
        if (scanRe.some((re) => re.test(blob))) {
            tags.push("scanner-or-imaging");
            if (kind === "unknown") kind = "scanner-or-imaging";
        }

        // Office / design apps
        const officeRe = [
            /microsoft\s*word/i, /microsoft\s*excel/i, /microsoft\s*powerpoint/i,
            /libreoffice/i, /openoffice/i, /word\s*\//i,
            /inDesign/i, /illustrator/i, /photoshop/i,
            /google\s*docs/i, /onlyoffice/i
        ];
        if (officeRe.some((re) => re.test(blob))) {
            tags.push("office-or-design");
            if (kind === "unknown") kind = "office-or-design";
        }

        // PDF tools
        const toolRe = [
            /pdf\.js/i, /pdf-lib/i, /itext/i, /pdfbox/i, /ghostscript/i,
            /qpdf/i, /pdftk/i, /nitro/i, /foxit/i, /preview\b/i
        ];
        if (toolRe.some((re) => re.test(blob))) {
            tags.push("pdf-tool");
            if (kind === "unknown") kind = "pdf-tool";
        }

        if (!producer && !creator) {
            tags.push("empty-metadata");
            if (kind === "unknown") kind = "empty-metadata";
        }

        return {
            kind,
            tags,
            producer: producer || null,
            creator: creator || null,
            title: title || null,
            keywords: keywords || null,
            likelyFromCcsEditor: isCcs,
            likelyPrintToPdf: tags.indexOf("print-to-pdf") >= 0,
            likelyScanOrImage: tags.indexOf("scanner-or-imaging") >= 0,
            summary: isCcs
                ? "Looks like it was produced/touched by CCS PDF Editor (Producer/Keywords)."
                : (tags.indexOf("print-to-pdf") >= 0
                    ? "Likely Print-to-PDF / virtual printer (Producer/Creator)."
                    : (tags.indexOf("scanner-or-imaging") >= 0
                        ? "Likely scanner or imaging pipeline (Producer/Creator)."
                        : (tags.indexOf("empty-metadata") >= 0
                            ? "No Producer/Creator metadata — origin unclear (stripped or minimal writer)."
                            : "Generic/unknown PDF writer: " + (producer || creator || "(none)"))))
        };
    }

    /**
     * Read Info-dict style metadata via pdf-lib (best-effort).
     */
    async function readPdfInfoMeta(arrayBuffer) {
        const PDF = PDFLibNS();
        const out = { producer: null, creator: null, title: null, keywords: null, hasCcsCatalog: false };
        if (!PDF || !PDF.PDFDocument) return out;
        try {
            const doc = await PDF.PDFDocument.load(arrayBuffer, {
                ignoreEncryption: true,
                updateMetadata: false
            });
            try {
                if (typeof doc.getProducer === "function") out.producer = doc.getProducer() || null;
                if (typeof doc.getCreator === "function") out.creator = doc.getCreator() || null;
                if (typeof doc.getTitle === "function") out.title = doc.getTitle() || null;
                if (typeof doc.getKeywords === "function") {
                    const kw = doc.getKeywords();
                    out.keywords = Array.isArray(kw) ? kw.join(", ") : (kw || null);
                }
            } catch (_) { /* ignore */ }
            try {
                const key = PDF.PDFName.of(CCS_CATALOG_KEY);
                out.hasCcsCatalog = !!(doc.catalog.lookup && doc.catalog.lookup(key));
            } catch (_) { /* ignore */ }
        } catch (e) {
            out.error = String(e && e.message || e);
        }
        return out;
    }

    /**
     * Stamp durable-ish CCS fingerprints into standard Info fields (often kept on re-save).
     */
    function stampCcsFingerprintMetadata(pdfDoc) {
        if (!pdfDoc) return;
        try {
            if (typeof pdfDoc.setProducer === "function") {
                pdfDoc.setProducer("CCS PDF Editor");
            }
            if (typeof pdfDoc.setCreator === "function") {
                pdfDoc.setCreator("CCS PDF Editor");
            }
            if (typeof pdfDoc.setKeywords === "function") {
                pdfDoc.setKeywords([
                    "CCSProject",
                    "CCS-PDF-Editor",
                    SCHEMA_VERSION,
                    "origin-tracked"
                ]);
            }
        } catch (_) { /* ignore */ }
    }

    /**
     * Record origin hash + provenance for the active session (call when a PDF is opened).
     */
    async function recordDocumentOrigin(arrayBuffer, meta) {
        try {
            const u8 = arrayBuffer instanceof Uint8Array
                ? arrayBuffer
                : new Uint8Array(arrayBuffer);
            const hash = await sha256Hex(u8);
            const hasProjectState = !!(meta && meta.hasProjectState);
            const info = await readPdfInfoMeta(arrayBuffer);
            const provenance = analyzePdfProvenance(info);

            const origin = {
                hash,
                at: new Date().toISOString(),
                byteLength: u8.byteLength,
                source: (meta && meta.source) || "open",
                hasProjectState,
                hasCcsCatalog: !!info.hasCcsCatalog,
                provenance
            };
            if (global.APP) {
                global.APP.documentOrigin = origin;
            }
            try {
                if (global.CcsWorkspace && typeof global.CcsWorkspace.activeDoc === "function") {
                    const doc = global.CcsWorkspace.activeDoc();
                    if (doc) doc.origin = origin;
                }
            } catch (_) { /* ignore */ }

            // Console report (always)
            console.log(
                "%c[CCS document open]",
                "font-weight:bold;color:#0af",
                "\n  originHash =", hash,
                "\n  bytes      =", u8.byteLength,
                "\n  projectState =", hasProjectState ? "YES (CCS schema present)" : "NO — no CCS project/audit payload",
                "\n  catalog CCSProject =", info.hasCcsCatalog ? "YES" : "NO",
                "\n  producer  =", provenance.producer || "(none)",
                "\n  creator   =", provenance.creator || "(none)",
                "\n  kind      =", provenance.kind,
                "\n  tags      =", provenance.tags.join(", ") || "(none)",
                "\n  note      =", provenance.summary
            );
            if (!hasProjectState) {
                console.warn(
                    "[CCS] No embedded project state (/CCSProject or legacy trailer).\n" +
                    "  • This file was never saved from CCS, or another app stripped our catalog data.\n" +
                    "  • originHash above is the fingerprint of *this* open — use it as the baseline for later seals.\n" +
                    "  • Provenance guess: " + provenance.summary
                );
            }

            return origin;
        } catch (e) {
            console.warn("[project-state] recordDocumentOrigin", e);
            return null;
        }
    }

    /**
     * Embed schema into pdfDoc catalog as /CCSProject.
     * @param {*} pdfDoc pdf-lib PDFDocument
     * @param {object} schema
     * @param {{ skipSeal?: boolean }} [opts]
     * @returns {Promise<{ hash: string, bytes: number, seal?: object }>}
     */
    async function embedProjectStateInPdfDoc(pdfDoc, schema, opts) {
        const PDF = PDFLibNS();
        if (!pdfDoc || !PDF) throw new Error("pdf-lib PDFDocument required");
        const { PDFName, PDFHexString, PDFString } = PDF;
        const options = opts || {};

        // Ensure we seal against a body *without* a prior project blob
        removeCcsProjectFromDoc(pdfDoc);

        let contentHash = null;
        let seal = null;
        const embeddedAt = new Date().toISOString();
        const baseSchema = stripSeal(Object.assign({}, schema, {
            version: schema.version || SCHEMA_VERSION,
            storage: "catalog-CCSProject",
            embeddedAt
        }));

        if (!options.skipSeal) {
            // Serialize content layer (pages, forms, flattened ink) — no /CCSProject yet
            const contentBytes = await pdfDoc.save({ updateFieldAppearances: true });
            contentHash = await sha256Hex(contentBytes);
            seal = await buildSeal(baseSchema, contentHash, {
                purpose: schema.purpose || (schema.flattenedExport ? "export-pdf" : "project")
            });
        }

        const schemaToWrite = Object.assign({}, baseSchema);
        if (seal) schemaToWrite.seal = seal;

        const compressed = await gzipJson(schemaToWrite);
        const hash = await hashPayload(compressed);
        const hex = u8ToHex(compressed);

        const ctx = pdfDoc.context;
        removeCcsProjectFromDoc(pdfDoc);

        const dict = ctx.obj({
            Type: PDFName.of("CCSProject"),
            Ver: PDFString.of(SCHEMA_VERSION),
            Fmt: PDFName.of("GZipHex"),
            Hash: PDFString.of(hash),
            Data: PDFHexString.of(hex)
        });
        const ref = ctx.register(dict);
        pdfDoc.catalog.set(PDFName.of(CCS_CATALOG_KEY), ref);

        // Standard Info fields — more likely to survive foreign re-saves than private catalog alone
        stampCcsFingerprintMetadata(pdfDoc);

        console.log(
            "[project-state] seal contentHash=", contentHash && contentHash.slice(0, 16) + "…",
            "schemaHash=", seal && seal.schemaHash && seal.schemaHash.slice(0, 16) + "…",
            "originHash=", seal && seal.originHash && seal.originHash.slice(0, 16) + "…",
            "sig=", seal && seal.sig && seal.sig.signature ? "yes" : "no"
        );

        return {
            hash,
            bytes: compressed.length,
            hexChars: hex.length,
            seal,
            contentHash
        };
    }

    /**
     * Verify embedded seal (schemaHash always; contentHash is informational).
     * @returns {Promise<{ ok: boolean, checks: object, schema: object|null }>}
     */
    async function verifyProjectSeal(schema) {
        const checks = {
            hasSeal: false,
            schemaHashOk: null,
            hasContentHash: false,
            hasOriginHash: false,
            signatureOk: null
        };
        if (!schema || !schema.seal) {
            return { ok: false, checks, schema: schema || null, message: "No seal" };
        }
        checks.hasSeal = true;
        checks.hasContentHash = !!schema.seal.contentHash;
        checks.hasOriginHash = !!schema.seal.originHash;

        const without = stripSeal(schema);
        const recomputed = await sha256Hex(new TextEncoder().encode(stableStringify(without)));
        checks.schemaHashOk = recomputed === schema.seal.schemaHash;
        checks.recomputedSchemaHash = recomputed;
        checks.storedSchemaHash = schema.seal.schemaHash;

        if (global.CcsProjectCrypto && typeof global.CcsProjectCrypto.verifySealSignature === "function"
            && schema.seal.sig && schema.seal.sig.signature) {
            const sigRes = await global.CcsProjectCrypto.verifySealSignature(schema.seal);
            checks.signatureOk = sigRes.ok;
            checks.signatureMessage = sigRes.message;
        } else {
            checks.signatureOk = null;
            checks.signatureMessage = "No device signature on seal";
        }

        const ok = checks.schemaHashOk === true
            && (checks.signatureOk === true || checks.signatureOk === null);
        let message;
        if (!checks.schemaHashOk) {
            message = "Schema seal MISMATCH — project payload may have been altered.";
        } else if (checks.signatureOk === false) {
            message = "Schema hash OK but device signature INVALID.";
        } else if (checks.signatureOk === true) {
            message = "Schema seal OK + device signature valid.";
        } else {
            message = "Schema seal OK (unsigned or no crypto module).";
        }

        return { ok, checks, schema, message };
    }

    /**
     * Read /CCSProject from a loaded pdf-lib document.
     * @returns {Promise<object|null>} schema or null
     */
    async function extractProjectStateFromPdfDoc(pdfDoc, opts) {
        const options = opts || {};
        const PDF = PDFLibNS();
        if (!pdfDoc || !PDF) return null;
        const { PDFName, PDFHexString, PDFString, PDFRawStream, PDFDict } = PDF;

        let looked = null;
        try {
            looked = pdfDoc.catalog.lookup(PDFName.of(CCS_CATALOG_KEY));
        } catch (_) {
            return null;
        }
        if (!looked) return null;

        // May be a dict or need further lookup
        let dict = looked;
        if (!(dict.lookup && dict.get)) {
            try {
                dict = pdfDoc.context.lookup(looked);
            } catch (_) {
                return null;
            }
        }
        if (!dict || !dict.lookup) return null;

        let hash = "";
        try {
            const h = dict.lookup(PDFName.of("Hash"));
            if (h && h.decodeText) hash = h.decodeText();
            else if (h && h.asString) hash = h.asString();
            else if (h) hash = String(h);
            // PDFString asString includes parentheses sometimes
            hash = String(hash).replace(/^\(|\)$/g, "");
        } catch (_) { hash = ""; }

        let compressed = null;
        try {
            const data = dict.lookup(PDFName.of("Data"));
            if (!data) return null;
            if (data.asBytes) {
                // PDFHexString → raw bytes of decoded hex payload
                compressed = data.asBytes();
            } else if (data instanceof PDFRawStream || (data.contents != null)) {
                compressed = data.contents
                    ? new Uint8Array(data.contents)
                    : null;
            } else if (data.asString) {
                // hex string form
                compressed = hexToU8(data.asString());
            }
        } catch (e) {
            console.warn("[CCSProject] Data read failed", e);
            return null;
        }
        if (!compressed || !compressed.length) return null;

        if (hash && typeof global.computeHash === "function") {
            const actual = await hashPayload(compressed);
            if (actual !== hash) {
                if (!options.allowHashMismatch) {
                    console.error("[CCSProject] integrity mismatch");
                    if (typeof global.customAlert === "function") {
                        global.customAlert(
                            "Embedded project state failed integrity check. The file may have been tampered with or corrupted.",
                            "❌ Security Warning"
                        );
                    }
                    return null;
                }
                console.warn("[CCSProject] integrity mismatch (continuing)");
            }
        }

        try {
            return await gunzipToJson(compressed);
        } catch (e) {
            console.warn("[CCSProject] decompress/parse failed", e);
            return null;
        }
    }

    function findLegacyMarkerIndex(bytes) {
        const markerBytes = new TextEncoder().encode(LEGACY_MARKER);
        for (let i = bytes.length - markerBytes.length; i >= 0; i--) {
            let match = true;
            for (let j = 0; j < markerBytes.length; j++) {
                if (bytes[i + j] !== markerBytes[j]) {
                    match = false;
                    break;
                }
            }
            if (match) return i;
        }
        return -1;
    }

    /**
     * Legacy trailer: \n---CCS-SAVE-STATE--- + hash + delim + gzip
     * @returns {Promise<{ schema: object, pdfBytes: ArrayBuffer, source: string }|null|{ abort: true }>}
     */
    async function extractLegacyTrailer(arrayBuffer) {
        const bytes = new Uint8Array(arrayBuffer);
        const markerIndex = findLegacyMarkerIndex(bytes);
        if (markerIndex === -1) return null;

        const payloadBytes = bytes.slice(markerIndex + LEGACY_MARKER.length);
        const delimBytes = new TextEncoder().encode(LEGACY_DELIM);
        let delimIndex = -1;
        for (let i = 0; i < payloadBytes.length - delimBytes.length; i++) {
            let ok = true;
            for (let j = 0; j < delimBytes.length; j++) {
                if (payloadBytes[i + j] !== delimBytes[j]) {
                    ok = false;
                    break;
                }
            }
            if (ok) {
                delimIndex = i;
                break;
            }
        }

        let jsonStr = null;
        if (delimIndex !== -1) {
            const savedHash = new TextDecoder().decode(payloadBytes.slice(0, delimIndex));
            const compressedData = payloadBytes.slice(delimIndex + delimBytes.length);
            const actualHash = await hashPayload(compressedData);
            if (actualHash !== savedHash) {
                if (typeof global.customAlert === "function") {
                    global.customAlert(
                        "Project file integrity check failed. The file may be corrupted or tampered with.",
                        "❌ Security Warning"
                    );
                }
                return { abort: true };
            }
            jsonStr = await global.decompressData(compressedData);
        } else {
            if (typeof global.customAlert === "function") {
                global.customAlert(
                    "Project file was saved without an Integrity checksum. Edits have been loaded but cannot be verified as genuine.",
                    "❌ Security Warning"
                );
            }
            jsonStr = new TextDecoder("utf-8").decode(payloadBytes);
        }

        if (jsonStr == null) return { abort: true };
        const schema = JSON.parse(jsonStr);
        const pdfBytes = arrayBuffer.slice(0, markerIndex);
        return { schema, pdfBytes, source: "trailer" };
    }

    /**
     * Unified open path: legacy trailer first (if present), else catalog /CCSProject.
     * When dual-write files exist, trailer is preferred (same content, no extra pdf-lib parse).
     * Catalog is preferred for pure .pdf files resaved without a trailer.
     * @param {ArrayBuffer} arrayBuffer
     * @returns {Promise<{ schema: object|null, pdfBytes: ArrayBuffer, source: string, abort?: boolean }>}
     */
    async function extractProjectStateFromBytes(arrayBuffer) {
        // 1) Legacy trailer (cheap byte scan)
        try {
            const legacy = await extractLegacyTrailer(arrayBuffer);
            if (legacy && legacy.abort) {
                return { schema: null, pdfBytes: arrayBuffer, source: "trailer-failed", abort: true };
            }
            if (legacy && legacy.schema) {
                // Prefer catalog when dual-written? Trailer is fine and faster.
                // Also try catalog on the stripped PDF for future pure-PDF saves.
                return {
                    schema: legacy.schema,
                    pdfBytes: legacy.pdfBytes,
                    source: "trailer"
                };
            }
        } catch (e) {
            console.warn("[project-state] trailer parse", e);
        }

        // 2) Catalog /CCSProject via pdf-lib (pure PDF or strip-and-keep)
        try {
            const PDF = PDFLibNS();
            if (PDF && PDF.PDFDocument) {
                const doc = await PDF.PDFDocument.load(arrayBuffer, {
                    ignoreEncryption: true,
                    updateMetadata: false
                });
                const schema = await extractProjectStateFromPdfDoc(doc);
                if (schema) {
                    return {
                        schema,
                        pdfBytes: arrayBuffer,
                        source: "catalog"
                    };
                }
            }
        } catch (e) {
            console.warn("[project-state] catalog extract", e);
        }

        return { schema: null, pdfBytes: arrayBuffer, source: "none" };
    }

    /**
     * Build final project file bytes: pure PDF with catalog /CCSProject only.
     * Legacy ---CCS-SAVE-STATE--- trailer is opt-in (dualWriteTrailer: true) for tools
     * that still scrape trailers; default is false.
     *
     * @param {*} pdfDoc
     * @param {object} schema
     * @param {{ dualWriteTrailer?: boolean }} [opts]
     * @returns {Promise<Uint8Array>}
     */
    async function savePdfWithProjectState(pdfDoc, schema, opts) {
        const options = opts || {};
        const dual = options.dualWriteTrailer === true;

        const meta = await embedProjectStateInPdfDoc(pdfDoc, schema);
        console.log(
            "[project-state] embedded catalog CCSProject gzipBytes=", meta.bytes,
            "hexChars=", meta.hexChars,
            "hash=", meta.hash && meta.hash.slice(0, 12) + "…"
        );

        let pdfBytes = await pdfDoc.save();

        if (dual) {
            const compressed = await gzipJson(
                Object.assign({}, schema, {
                    version: schema.version || SCHEMA_VERSION,
                    storage: "catalog+trailer"
                })
            );
            const hash = await hashPayload(compressed);
            const marker = new TextEncoder().encode(LEGACY_MARKER);
            const delim = new TextEncoder().encode(LEGACY_DELIM);
            const hashBytes = new TextEncoder().encode(hash);
            const out = new Uint8Array(
                pdfBytes.length + marker.length + hashBytes.length + delim.length + compressed.length
            );
            out.set(pdfBytes, 0);
            let o = pdfBytes.length;
            out.set(marker, o); o += marker.length;
            out.set(hashBytes, o); o += hashBytes.length;
            out.set(delim, o); o += delim.length;
            out.set(compressed, o);
            pdfBytes = out;
            console.log("[project-state] dual-wrote legacy trailer (opt-in)");
        }

        return pdfBytes;
    }

    global.CcsProjectState = {
        SCHEMA_VERSION,
        CCS_CATALOG_KEY,
        u8ToHex,
        hexToU8,
        sha256Hex,
        stableStringify,
        stripSeal,
        buildSeal,
        recordDocumentOrigin,
        analyzePdfProvenance,
        readPdfInfoMeta,
        stampCcsFingerprintMetadata,
        verifyProjectSeal,
        removeCcsProjectFromDoc,
        embedProjectStateInPdfDoc,
        extractProjectStateFromPdfDoc,
        extractLegacyTrailer,
        extractProjectStateFromBytes,
        savePdfWithProjectState,
        hashPayload
    };
})(typeof window !== "undefined" ? window : globalThis);
