/**
 * Asset Import Service — the asset-as-embed core (Phase B).
 *
 * Replaced the old "upload as a self-owning `asset` entity, convert later"
 * flow (the `uploadAsset` primitive + a `convertAsset` orchestrator, both
 * removed in Phase B2/E). An import now:
 *
 *   1. Stores the binary as a FREE-STANDING asset owned by the FOLDER (the
 *      repo), via graph.uploadAsset's `owner` override (Phase A). The blob +
 *      assets.db row belong to the folder, never to any entity — so deleting
 *      the entity that embeds it leaves the asset intact + re-embeddable.
 *   2. Seeds a properly-TYPED graph entity whose BODY embeds the asset, exactly
 *      as a user would type it — the reference lives in the markdown, not the
 *      frontmatter:
 *        image                 → `material`, body `![name](url)` (inline embed)
 *        opaque binary         → `material`, body `[name](url)` (download link)
 *        docx / pdf            → `document`, body = a `[name](url)` source link
 *                                (extraction is lossy) + an "Extracted text"
 *                                heading + the extracted text
 *        xlsx                  → `dataset` × N (pure-CSV body; the CSV is a
 *                                faithful translation so no body reference —
 *                                the source xlsx is invisible `asset_ref`
 *                                provenance in frontmatter, the lone exception)
 *
 * Decisions (see ~/.claude/plans/asset-as-embed.md; body-embed model 2026-06-01):
 *   - NO asset-text sidecar is written. Extraction runs in-memory ONLY to seed
 *     the body, which the vector indexer indexes natively.
 *   - Body embeds point at the FREE-STANDING, folder-owned asset (Phase A/B
 *     storage is unchanged → still delete-safe, re-embeddable, many-to-many).
 *     A broken/missing embed renders as a compact inline note via the editor's
 *     image-render layer (live-preview.ts), NOT detail-page chrome.
 *   - On entity-create failure AFTER the blob is stored, roll back with the same
 *     3-step compensating delete asset-finalize-service uses: delete any created
 *     entity (keepAssets) → delete the blob → delete the index row. (Relaxes to
 *     "leave the folder-owned asset" once Phase D surfaces ownerless assets.)
 *   - xlsx with more than XLSX_SHEET_CAP sheets does NOT fan out — it falls back
 *     to a single `material` (the binary is preserved; no unbounded entity churn).
 *
 *   IMPORT(file, folder)
 *     ├─ store bytes  → {assetId}/{filename}   owner = { folder, repo }
 *     ├─ seed body (in-memory; NO sidecar) — embed/link in the markdown
 *     └─ create typed entity(ies)
 *          image/opaque → material · docx/pdf → document · xlsx → dataset×N
 *   FAIL after store  → delete created entities (keepAssets) → delete blob → delete index row
 */
import { extname, basename } from 'path';
import { resolveFormat } from '../core/format-registry.js';
import { isAssetUploadExtension, safeFilename } from './assets/base.js';
import { publicUrlForAssetId } from './assets/index.js';
import { createWithCollisionRetry } from './import-service.js';
import { extractSidecarText } from './asset-sidecar-service.js';
import { convertDocxToMarkdown, convertPdfToNoteText, convertXlsxToDatasets, XLSX_SHEET_CAP, CONVERT_MAX_BYTES, } from './convert-service.js';
import { log } from '../utils/log.js';
const slugify = (str) => str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const formatName = (slug) => slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
/** Map a file's format extractor to the typed entity it imports to. */
function targetTypeFor(extractor) {
    if (extractor === 'pdf' || extractor === 'docx')
        return 'document';
    if (extractor === 'xlsx')
        return 'dataset';
    // image-caption (images) + null (opaque: zip/fig/video/audio/psd/ai/sketch)
    return 'material';
}
/**
 * Import a binary as a typed graph entity that embeds a free-standing,
 * folder-owned asset. Returns the assetId + every created entity. Throws on
 * unsupported extension / empty buffer / unconfigured asset storage, and on
 * entity-create failure (after rolling back the stored blob + index row).
 */
export async function importAssetAsEntity(opts) {
    const { graph, buffer, folderPath, user, skipCommit = false } = opts;
    const onlyName = basename(opts.filename);
    const ext = extname(onlyName).toLowerCase();
    if (!isAssetUploadExtension(onlyName)) {
        throw new Error(`Unsupported asset extension: ${ext || '(none)'}`);
    }
    if (!buffer || buffer.length === 0) {
        throw new Error('Asset import received an empty buffer');
    }
    // Probe asset storage before mutating anything — a clear error beats a
    // half-stored asset with nowhere to point at (mirrors uploadAsset).
    try {
        await graph.listAssets('asset', '__check__');
    }
    catch (err) {
        if (err.message?.includes('not configured')) {
            throw new Error('Asset storage is not configured for this workspace.');
        }
        // entity-not-found etc. just means storage IS configured.
    }
    // The asset-as-embed model requires the immutable-key index (assets.db): the
    // body points at the stored binary by assetId. Verify it's wired BEFORE
    // uploading — otherwise uploadAsset could store a legacy blob and return no
    // assetId, and the post-upload guard would throw with the bytes orphaned.
    if (!graph.getAssetIndex()) {
        throw new Error('Asset import requires the immutable-key asset index (assets.db).');
    }
    const format = resolveFormat(onlyName);
    const targetType = targetTypeFor(format.extractor);
    const stem = onlyName.replace(/\.[^.]+$/, '');
    const baseId = (slugify(stem).slice(0, 80).replace(/-$/, '')) || `asset-${Date.now()}`;
    const name = opts.name ?? formatName(baseId);
    const now = new Date().toISOString();
    const userHandle = user?.email?.split('@')[0] ?? user?.name ?? 'import';
    // ── 1. Store the binary FOLDER-OWNED (free-standing). ──────────────────────
    // entityType/entityId args are ignored for ownership when `owner` is set;
    // we pass the target type + baseId as harmless metadata. The immutable-key
    // path returns the assetId + the index filename we need for rollback.
    const storedFilename = `${Date.now()}-${safeFilename(onlyName)}`;
    const uploaded = await graph.uploadAsset(targetType, baseId, storedFilename, buffer, undefined, {
        uploaderUserId: opts.uploaderUserId ?? null,
        owner: { type: 'folder', id: graph.repoName },
    });
    const assetId = uploaded.assetId;
    if (!assetId) {
        // No immutable-key index wired — the asset-as-embed model requires it.
        throw new Error('Asset import requires the immutable-key asset index (assets.db).');
    }
    const asset_url = uploaded.publicUrl;
    const indexedFilename = uploaded.filename;
    // ── 2–3. Seed the body/bodies (in-memory, NO sidecar) + create the typed
    // entity(ies). On ANY failure, roll back the entities (inside the helper)
    // AND the blob we just stored (here) — the import owns the new asset, so an
    // aborted import must leave nothing behind. ────────────────────────────────
    const isImage = format.extractor === 'image-caption';
    let created;
    try {
        created = await seedAndCreateEntities(graph, {
            buffer, ext, targetType, baseId, name, assetId, assetUrl: asset_url, isImage,
            folderPath, user, now, userHandle,
        });
    }
    catch (err) {
        await graph.deleteAssetById(assetId, indexedFilename);
        throw err instanceof Error ? err : new Error('Asset import failed');
    }
    if (!skipCommit) {
        const git = graph.getGitService();
        if (git) {
            const commitUser = user ?? { id: 'import', name: 'Import', email: 'import@studiograph' };
            git.commitAll(`Import ${onlyName} → ${created.length} ${created.length === 1 ? 'entry' : 'entries'}`, commitUser);
        }
    }
    return { assetId, asset_url, entities: created };
}
/**
 * The second import "door": create a properly-TYPED entity that embeds an asset
 * ALREADY in the store — by its assetId — instead of fresh bytes. This is what
 * makes deletion reversible: delete an entry, the free-standing asset survives
 * in the manager, and this re-embeds it into a new entry. Same body-seeding as
 * the import door (auto-derived type, re-extracted text for docs/sheets), but
 * NO upload and NO asset-rollback — the asset pre-exists and may be embedded
 * elsewhere, so a create failure only rolls back the new entities.
 *
 * Throws if the asset doesn't exist, storage isn't configured, or the stored
 * bytes can't be read back for extraction.
 */
export async function createEntityFromAsset(opts) {
    const { graph, assetId, folderPath, user, skipCommit = false } = opts;
    const index = graph.getAssetIndex();
    if (!index) {
        throw new Error('Asset operations require the immutable-key asset index (assets.db).');
    }
    const row = index.get(assetId);
    if (!row)
        throw new Error(`Asset ${assetId} not found`);
    const service = graph.getAssetService();
    if (!service)
        throw new Error('Asset storage is not configured for this workspace.');
    // Derive the type + extractor from the STORED filename (carries the original
    // extension), exactly as import derives them from the upload filename.
    const ext = extname(row.filename).toLowerCase();
    const format = resolveFormat(row.filename);
    const targetType = targetTypeFor(format.extractor);
    const isImage = format.extractor === 'image-caption';
    // Only documents/datasets read the bytes (to re-extract text for the body);
    // images/opaque materials seed their body from the URL alone. And an
    // oversized doc/sheet would fall back to a download-only material anyway, so
    // skip the (potentially large) blob fetch + force the material seed — using
    // the size recorded in the index. This avoids streaming bytes we'd discard.
    const oversized = row.size != null && row.size > CONVERT_MAX_BYTES;
    const needsExtraction = (targetType === 'document' || targetType === 'dataset') && !oversized;
    let buffer = Buffer.alloc(0);
    if (needsExtraction) {
        try {
            buffer = await service.download(`${assetId}/${row.filename}`);
        }
        catch (err) {
            throw new Error(`Could not read stored asset ${assetId}: ${err instanceof Error ? err.message : err}`);
        }
    }
    // Without extraction (image/opaque, or an oversized doc/sheet) the body is a
    // material embed/link built from the URL — matches the import door's fallback.
    const seedType = needsExtraction ? targetType : 'material';
    const asset_url = publicUrlForAssetId(assetId, row.filename);
    // Prefer the asset's stored name; fall back to a title-cased filename stem.
    const stem = basename(row.filename).replace(/^\d+-/, '').replace(/\.[^.]+$/, '');
    const baseId = (slugify(stem).slice(0, 80).replace(/-$/, '')) || `asset-${assetId.slice(0, 8)}`;
    const name = opts.name ?? row.name ?? formatName(baseId);
    const now = new Date().toISOString();
    const userHandle = user?.email?.split('@')[0] ?? user?.name ?? 'web';
    const created = await seedAndCreateEntities(graph, {
        buffer, ext, targetType: seedType, baseId, name, assetId, assetUrl: asset_url, isImage,
        folderPath, user, now, userHandle,
    });
    if (!skipCommit) {
        const git = graph.getGitService();
        if (git) {
            const commitUser = user ?? { id: 'web', name: 'Web', email: 'web@studiograph' };
            git.commitAll(`Create ${created.length === 1 ? 'entry' : 'entries'} from asset ${name}`, commitUser);
        }
    }
    return { assetId, asset_url, entities: created };
}
/**
 * Build the typed-entity unit(s) for a stored asset, seeding the body with a
 * markdown embed/link to the free-standing asset — exactly what a user would
 * type. The reference lives in the BODY, never the frontmatter (the lone
 * exception is dataset, see below). By kind:
 *   - image  → `material`, body `![name](url)` (renders inline in the editor).
 *   - opaque → `material`, body `[name](url)` (can't extract; a plain link).
 *   - docx/pdf → `document`, body = a `[name](url)` source link + extracted text.
 *   - xlsx → `dataset` × N (pure-CSV body, one per non-empty sheet). A CSV body
 *     can't hold a markdown link, so the source xlsx is recorded as invisible
 *     `asset_ref` provenance in frontmatter (added by the caller via extra),
 *     NOT a body embed. Falls back to a single material when the sheet count
 *     exceeds the cap / there are no usable sheets.
 *
 * Guiding principle: render a body reference only when the body can't faithfully
 * hold the asset — image embed IS the asset, opaque needs a link, a document's
 * extraction is lossy so it links the original, a dataset's CSV is faithful so
 * it needs nothing visible.
 */
async function seedUnits(buffer, ext, targetType, baseId, name, assetId, assetUrl, isImage) {
    // A single material wrapping the binary — the type when there's nothing to
    // extract (image/opaque) AND the fallback when we choose NOT to parse a
    // doc/sheet (oversized, too many sheets, no usable data). Its body is the
    // asset itself: an inline image embed for images, a download link otherwise.
    const materialBody = isImage ? `![${name}](${assetUrl})` : `[${name}](${assetUrl})`;
    const materialFallback = () => [
        { entityType: 'material', baseId, name, content: materialBody },
    ];
    if (targetType === 'document') {
        // docx → structured markdown (mammoth→turndown); pdf → reflowed text.
        // pdf/docx with no extractable text still seeds the download link (NOT an
        // error — the original is reachable, the body just has no prose).
        let text = '';
        if (ext === '.docx') {
            // DoS guard: mammoth parses docx synchronously on the request thread.
            // A doc past CONVERT_MAX_BYTES is preserved as a material (download link)
            // rather than parsed (same guard the convert + sidecar paths use). pdf is
            // already size-capped inside extractSidecarText, so it needs no guard.
            if (buffer.length > CONVERT_MAX_BYTES) {
                log.info(`[asset-import] ${name}: ${buffer.length} bytes > ${CONVERT_MAX_BYTES} cap; importing as material (unparsed)`);
                return materialFallback();
            }
            // mammoth throws on an encrypted / corrupt / non-OOXML .docx. Degrade to a
            // download-only material rather than aborting the import (which would roll
            // back the stored binary) — matches the pdf branch, where extractSidecarText
            // swallows errors to '' and still produces a download-only document.
            try {
                text = await convertDocxToMarkdown(buffer);
            }
            catch (err) {
                log.warn(`[asset-import] ${name}: docx parse failed (${err instanceof Error ? err.message : err}); importing as material`);
                return materialFallback();
            }
        }
        else {
            const extracted = await extractSidecarText(buffer, 'pdf', baseId);
            text = convertPdfToNoteText(extracted.text);
        }
        // Body layout: the source file FIRST (renders as a download chip — the
        // original is one click away since extraction is lossy), then a heading
        // that frames the body as machine-extracted (not authored) text, then the
        // text itself. An empty-text pdf seeds just the chip (no empty heading).
        // The link text is the file's name (same as the material/opaque chips), so
        // every imported asset chip reads alike; the chip's own ↓-button carries
        // the download affordance, so the label needn't repeat it.
        const sourceLink = `[${name}](${assetUrl})`;
        const parts = text ? [sourceLink, '## Extracted text', text] : [sourceLink];
        const body = parts.join('\n\n');
        return [{ entityType: 'document', baseId, name, content: body }];
    }
    if (targetType === 'dataset') {
        // DoS guard: SheetJS decompresses + parses the whole workbook synchronously
        // before the sheet cap can apply, so bound the input bytes first.
        if (buffer.length > CONVERT_MAX_BYTES) {
            log.info(`[asset-import] ${name}: ${buffer.length} bytes > ${CONVERT_MAX_BYTES} cap; importing as material (unparsed)`);
            return materialFallback();
        }
        // SheetJS throws on an encrypted / corrupt workbook. Degrade to a
        // download-only material rather than aborting the import + rolling back the
        // stored binary (parity with the docx + empty-pdf fallbacks).
        let conv;
        try {
            conv = convertXlsxToDatasets(buffer, name, { maxSheets: XLSX_SHEET_CAP });
        }
        catch (err) {
            log.warn(`[asset-import] ${name}: xlsx parse failed (${err instanceof Error ? err.message : err}); importing as material`);
            return materialFallback();
        }
        if (conv.exceededCap) {
            // Too many sheets to fan out safely — keep the binary as a material.
            log.info(`[asset-import] ${name}: ${conv.totalSheets} sheets > cap ${XLSX_SHEET_CAP}; importing as material`);
            return materialFallback();
        }
        if (conv.totalSheets === 0) {
            // No non-empty sheets — nothing to tabulate; keep the binary as material.
            return materialFallback();
        }
        // One dataset per sheet. The CSV body is a FAITHFUL translation of the data
        // so it needs no visible source reference; asset_ref (added by the caller)
        // records the source xlsx as invisible provenance, source_sheet the sheet.
        return conv.sheets.map(s => ({
            entityType: 'dataset',
            baseId: slugify(s.name) || `${baseId}-sheet`,
            name: s.name,
            content: s.csv,
            extra: { source_sheet: s.sheetName, asset_ref: assetId },
        }));
    }
    // material — image or opaque binary. The body IS the asset (inline image
    // embed, or a download link for opaque binaries). No text extraction.
    return materialFallback();
}
/**
 * Roll back entities created by a failed seed→create (mirrors
 * asset-finalize-service). `keepAssets` so it NEVER touches the blob — the
 * asset's lifecycle is the caller's concern: the import door rolls the blob
 * back too (it just created it via graph.deleteAssetById); the
 * create-from-asset door leaves the pre-existing, possibly-shared asset alone.
 * Best-effort + isolated so the original error still surfaces.
 */
async function rollbackEntities(graph, created, user) {
    for (const e of created) {
        try {
            await graph.delete(e.entityType, e.entityId, `Rollback failed asset import: ${e.entityId}`, user, { keepAssets: true });
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            if (!/not found/i.test(msg)) {
                log.warn(`[asset-import] rollback delete of ${e.entityType}/${e.entityId} failed: ${msg}`);
            }
        }
    }
}
/**
 * Seed the typed-entity body/bodies for an ALREADY-STORED asset and create the
 * entities. Shared by both doors — import (door 1, fresh upload) and
 * create-from-asset (door 2, existing assetId). On any failure rolls back ONLY
 * the entities it created (never the asset) and rethrows, so each door owns its
 * own asset-rollback policy.
 */
async function seedAndCreateEntities(graph, p) {
    const units = await seedUnits(p.buffer, p.ext, p.targetType, p.baseId, p.name, p.assetId, p.assetUrl, p.isImage);
    const created = [];
    try {
        for (const unit of units) {
            // No universal `asset_ref` frontmatter: under the body-embed model the
            // reference lives IN THE BODY (image embed / download link), not the
            // header. The ONE exception is `dataset` — its body must stay valid CSV,
            // so it can't hold a body embed; it carries `asset_ref` (via unit.extra)
            // as invisible provenance back to the source xlsx instead.
            const data = {
                entity_type: unit.entityType,
                entity_id: unit.baseId,
                name: unit.name,
                created_at: p.now,
                updated_at: p.now,
                created_by: p.userHandle,
                updated_by: p.userHandle,
                ...(unit.extra ?? {}),
            };
            const { entityId, path } = await createWithCollisionRetry(graph, {
                entityType: unit.entityType,
                entityId: unit.baseId,
                data,
                content: unit.content,
                folderPath: p.folderPath,
                skipCommit: true,
                user: p.user,
            });
            created.push({ entityId, entityType: unit.entityType, filePath: path });
        }
    }
    catch (err) {
        await rollbackEntities(graph, created, p.user);
        throw err;
    }
    return created;
}
//# sourceMappingURL=asset-import-service.js.map