/**
 * File Import Service
 *
 * Converts external files into Studiograph entities.
 * - .md → note entity (parses frontmatter if present)
 * - .txt → note entity (plain text as body)
 * - .csv → dataset entity (infers column schema)
 * - .pdf → note entity (extracts text via pdf-parse)
 *
 * A .csv dropped here becomes a structured `dataset` entity (in-graph,
 * queryable, editable in the grid). Uploading a .csv through the asset
 * upload UI instead lands it as an opaque `asset` with
 * `asset_kind: spreadsheet` — the two paths coexist.
 */
import { extname, basename, dirname } from 'path';
import matter from 'gray-matter';
import { importServiceExtensions } from '../core/format-registry.js';
/** Convert a string to kebab-case for use as an entity ID. */
function slugify(str) {
    return str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
/** Convert a relative directory path (`'My Documents/Q1 Reports'`) into a
 *  kebab-case folder path (`'my-documents/q1-reports'`). Empty / `'.'`
 *  yields `''`. Each segment is slugified independently so multi-segment
 *  paths keep their structure. */
function slugifyFolderPath(dir) {
    if (!dir || dir === '.')
        return '';
    return dir
        .split(/[/\\]/)
        .map(slugify)
        .filter(Boolean)
        .join('/');
}
/** Convert kebab-case slug to the title-cased fallback the UI uses when no
 *  sidecar override is present. Mirrors `toDisplayName(slug)` in
 *  workspace.svelte.ts so we only persist a sidecar when the user's original
 *  segment carries information the fallback can't recover (`Schema_OS`,
 *  `HR`, `Q1 Reports`). Skipping look-alikes keeps the manifest sparse. */
function toDisplayFallback(slug) {
    return slug
        .split('-')
        .map(s => s.charAt(0).toUpperCase() + s.slice(1))
        .join(' ');
}
/** Pick the best `name` fallback for an imported entity when the source
 *  carries no frontmatter `name`/`title`. Mirrors the sparse rule used for
 *  folder display sidecars: prefer the original filename stem only when it
 *  carries casing the slug → title-case fallback can't recover (e.g.
 *  "Schema_OS.md" → slug "schema-os" → fallback "Schema Os" ≠ original).
 *  Otherwise stick with the title-case fallback so kebab and already
 *  title-cased filenames keep producing the same display they do today.
 *
 *  When the entity_id was explicitly overridden (frontmatter / options) so
 *  that finalId differs from the natural slug of the filename, the explicit
 *  id is the source of truth — don't substitute the original stem. */
function deriveImportName(filename, finalId) {
    const ext = extname(filename);
    const originalStem = basename(filename, ext);
    const naturalSlug = slugify(originalStem);
    const slugFallbackName = formatName(finalId);
    if (finalId !== naturalSlug)
        return slugFallbackName;
    if (originalStem === naturalSlug)
        return slugFallbackName;
    if (originalStem === slugFallbackName)
        return slugFallbackName;
    return originalStem;
}
/** Walk an original directory string and the slug it produced, emitting a
 *  per-segment list of {full slug-path, original segment string} for any
 *  segment whose original differs from the slug fallback. The full slug-path
 *  is repo-relative within `targetFolderPath` (the caller's chosen base), so
 *  the returned paths are ready to pass to `recordFolderDisplays`. */
function deriveFolderDisplays(originalDir, targetFolderPath) {
    if (!originalDir || originalDir === '.')
        return [];
    const originalSegments = originalDir.split(/[/\\]/).filter(Boolean);
    const result = [];
    let acc = targetFolderPath ?? '';
    for (const original of originalSegments) {
        const slug = slugify(original);
        if (!slug)
            continue;
        acc = acc ? `${acc}/${slug}` : slug;
        // Two skip conditions:
        //   1. original === slug — already kebab on disk; no info to preserve.
        //   2. original === fallback — title-case fallback already round-trips.
        // Only when both fail does the original carry casing the fallback can't
        // recover (e.g. "Schema_OS", "HR", "iOS"), and we persist it.
        if (original !== slug && original !== toDisplayFallback(slug)) {
            result.push({ path: acc, display_name: original });
        }
    }
    return result;
}
/** Combine a target folder path (e.g. caller's chosen subfolder) with a
 *  per-file relative directory. Either or both may be empty. */
function joinFolderPaths(...parts) {
    return parts.filter((p) => Boolean(p)).join('/');
}
/** Create an entity, auto-suffixing the entity_id on `(type, id)` collisions
 *  (`foo` → `foo-2` → `foo-3` …) up to `maxAttempts` tries. The base id
 *  comes through unchanged when there's no collision. Returns the id that
 *  was actually used.
 *
 *  Exported so the asset upload service and the convert endpoint (P2) share
 *  this one collision algorithm instead of each maintaining an inline suffix
 *  loop (CQ2). */
export async function createWithCollisionRetry(graph, options, maxAttempts = 50) {
    let suffix = 0;
    while (true) {
        const entityId = suffix === 0 ? options.entityId : `${options.entityId}-${suffix + 1}`;
        try {
            const created = await graph.create({
                ...options,
                entityId,
                data: { ...options.data, entity_id: entityId },
            });
            return { entityId, path: created.path };
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            if (!msg.includes('already exists') || suffix >= maxAttempts)
                throw err;
            suffix++;
        }
    }
}
/**
 * Extensions the import path recognizes, sourced from the format registry
 * (`importServiceExtensions()`) so there's no parallel list to drift. Binary
 * asset extensions (.indd, .sketch, .psd, .ai, .fig, .pptx, …) are NOT in
 * this set — they go through `uploadAsset` in the route layer instead. .pdf
 * stays here for backwards compatibility (the agent tool still uses importFile
 * to import a PDF as a `note` entity); the web import dispatcher routes .pdf
 * to the asset path before reaching importFile.
 *
 * Note: `isSupportedFile` is consulted by the agent `import_file` tool only
 * AFTER `isAssetUploadExtension` has already claimed the asset extensions, so
 * the registry-derived set is observably identical to the historical literal
 * (the extra entries it carries — .xlsx/.avif/.heic — are all asset-upload
 * extensions, caught before this check).
 */
const SUPPORTED_EXTENSIONS = importServiceExtensions();
export function isSupportedFile(filename) {
    return SUPPORTED_EXTENSIONS.has(extname(filename).toLowerCase());
}
/**
 * Extract text content from a file for agent processing.
 * Handles text-based formats directly and binary formats (PDF) via extraction libraries.
 */
export async function extractText(filename, content) {
    const ext = extname(filename).toLowerCase();
    if (ext === '.pdf') {
        const buf = Buffer.from(content, 'base64');
        const { PDFParse } = await import('pdf-parse');
        const parser = new PDFParse({ data: buf });
        const result = await parser.getText();
        await parser.destroy();
        return result.text?.trim() ?? '';
    }
    if (ext === '.docx') {
        const buf = Buffer.from(content, 'base64');
        const mammoth = await import('mammoth');
        const result = await mammoth.extractRawText({ buffer: buf });
        return result.value?.trim() ?? '';
    }
    // For .md, parse out frontmatter and return content
    if (ext === '.md') {
        const parsed = matter(content);
        const meta = parsed.data.name || parsed.data.title
            ? `Title: ${parsed.data.name || parsed.data.title}\n\n`
            : '';
        return meta + parsed.content.trim();
    }
    // .txt, .csv, and others — return as-is
    return content.trim();
}
/**
 * Import a single file as a Studiograph entity.
 *
 * The `filename` may include a relative directory portion (e.g.
 * `decks/2025-q1/talk.md`); the directory is slugified and combined with
 * `options.folderPath` (the caller's chosen destination subfolder) to
 * produce the final on-disk location. This preserves user-organized
 * folder structure when a folder is dropped onto the import zone.
 *
 * Collisions on `(entity_type, entity_id)` are auto-suffixed (`talk` →
 * `talk-2` → `talk-3` …) rather than skipped, so dropping a folder twice
 * never silently swallows files.
 */
export async function importFile(graph, filename, content, options) {
    const ext = extname(filename).toLowerCase();
    const baseId = options?.entityId ?? slugify(basename(filename, ext));
    if (!baseId) {
        return { entityId: '', entityType: '', status: 'error', error: 'Could not generate entity ID from filename' };
    }
    // Combine the caller's target folder with the file's own relative
    // directory. e.g. importing `decks/talk.md` into target `2025-q1` →
    // final folderPath `2025-q1/decks`. Directory segments from the source
    // are slugified to match the workspace's kebab-case folder convention.
    const fileDir = dirname(filename);
    const fileSlugDir = slugifyFolderPath(fileDir);
    const combinedFolderPath = joinFolderPaths(options?.folderPath, fileSlugDir);
    const innerOptions = { ...options, folderPath: combinedFolderPath || undefined };
    // Capture original-casing display names for each source segment that the
    // slug can't round-trip ("Schema_OS" → "schema-os" → "Schema Os" ≠
    // original). These are written as per-folder sidecars after the entity
    // lands so the import path is the only place that auto-populates them.
    // First-writer-wins inside recordFolderDisplays — re-importing a folder
    // with the same shape never clobbers a manual rename.
    const folderDisplays = deriveFolderDisplays(fileDir, options?.folderPath);
    try {
        let result;
        // PDF needs binary data — handle before text conversion
        if (ext === '.pdf') {
            const buf = typeof content === 'string' ? Buffer.from(content, 'base64') : content;
            result = await importPdf(graph, baseId, buf, filename, innerOptions);
        }
        else {
            const text = typeof content === 'string' ? content : content.toString('utf-8');
            switch (ext) {
                case '.md':
                    result = await importMarkdown(graph, baseId, text, filename, innerOptions);
                    break;
                case '.txt':
                    result = await importText(graph, baseId, text, filename, innerOptions);
                    break;
                case '.csv':
                    result = await importCsv(graph, baseId, text, filename, innerOptions);
                    break;
                default:
                    return { entityId: baseId, entityType: '', status: 'error', error: `Unsupported file type: ${ext}` };
            }
        }
        // Sidecar pass: only after the entity write succeeds. Folder existence
        // is guaranteed at this point — graph.create()'s mkdir -p semantics
        // produced the directories we're writing into.
        if (result.status === 'created' && folderDisplays.length > 0) {
            const sidecarPaths = graph.recordFolderDisplays(folderDisplays);
            if (sidecarPaths.length > 0) {
                result = { ...result, sidecarPaths };
            }
        }
        return result;
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { entityId: baseId, entityType: options?.entityType ?? 'note', status: 'error', error: msg };
    }
}
/** Import a .md file. Frontmatter wins: if the file declares
 *  `entity_type`/`entity_id`, those are used; options.entityType/entityId
 *  are fallbacks; final fallback is `note` and the slugified filename.
 *  This preserves the explicit shape of structured markdown a user has
 *  already prepared (decks, references, datasets-by-hand, etc.) instead
 *  of overwriting it with a generic default. */
async function importMarkdown(graph, entityId, text, filename, options) {
    const parsed = matter(text);
    const fmType = typeof parsed.data.entity_type === 'string' ? parsed.data.entity_type : undefined;
    const fmId = typeof parsed.data.entity_id === 'string' ? parsed.data.entity_id : undefined;
    const entityType = (fmType ?? options?.entityType ?? 'note');
    const finalId = fmId ?? options?.entityId ?? entityId;
    const now = new Date().toISOString();
    const userHandle = options?.user
        ? (options.user.email?.split('@')[0] ?? options.user.name ?? 'import')
        : 'import';
    const data = {
        ...parsed.data,
        entity_type: entityType,
        entity_id: finalId,
        name: parsed.data.name ?? parsed.data.title ?? deriveImportName(filename, finalId),
        created_at: parsed.data.created_at ?? now,
        updated_at: now,
        // Only stamp created_by/updated_by when the source didn't declare them —
        // preserves attribution for files exported from another workspace.
        created_by: parsed.data.created_by ?? userHandle,
        updated_by: userHandle,
    };
    const created = await createWithCollisionRetry(graph, {
        entityType,
        entityId: finalId,
        data,
        content: parsed.content.trim(),
        folderPath: options?.folderPath,
        user: options?.user,
        commitMessage: options?.commitMessage ?? `Import ${entityType}: ${finalId}`,
        skipCommit: options?.skipCommit ?? true,
    });
    return { entityId: created.entityId, entityType, status: 'created', path: created.path };
}
/** Import a .txt file as a note entity. */
async function importText(graph, entityId, text, filename, options) {
    const entityType = (options?.entityType ?? 'note');
    const now = new Date().toISOString();
    const userHandle = options?.user
        ? (options.user.email?.split('@')[0] ?? options.user.name ?? 'import')
        : 'import';
    const created = await createWithCollisionRetry(graph, {
        entityType,
        entityId,
        data: {
            entity_type: entityType,
            entity_id: entityId,
            name: deriveImportName(filename, entityId),
            created_at: now,
            updated_at: now,
            created_by: userHandle,
            updated_by: userHandle,
        },
        content: text.trim(),
        folderPath: options?.folderPath,
        user: options?.user,
        commitMessage: options?.commitMessage ?? `Import note: ${entityId}`,
        skipCommit: options?.skipCommit ?? true,
    });
    return { entityId: created.entityId, entityType, status: 'created', path: created.path };
}
/** Import a .csv file as a dataset entity. Conventional CSV: the body is the
 *  data and the header row is the column list — no schema is inferred. */
async function importCsv(graph, entityId, text, filename, options) {
    const entityType = (options?.entityType ?? 'dataset');
    if (text.trim() === '') {
        return { entityId, entityType, status: 'error', error: 'CSV is empty' };
    }
    const now = new Date().toISOString();
    const userHandle = options?.user
        ? (options.user.email?.split('@')[0] ?? options.user.name ?? 'import')
        : 'import';
    const created = await createWithCollisionRetry(graph, {
        entityType,
        entityId,
        data: {
            entity_type: entityType,
            entity_id: entityId,
            name: deriveImportName(filename, entityId),
            created_at: now,
            updated_at: now,
            created_by: userHandle,
            updated_by: userHandle,
        },
        content: text.trim(),
        folderPath: options?.folderPath,
        user: options?.user,
        commitMessage: options?.commitMessage ?? `Import dataset: ${entityId}`,
        skipCommit: options?.skipCommit ?? true,
    });
    return { entityId: created.entityId, entityType, status: 'created', path: created.path };
}
/** Import a .pdf file as a note entity. Extracts text via pdf-parse. */
async function importPdf(graph, entityId, data, filename, options) {
    const { PDFParse } = await import('pdf-parse');
    const parser = new PDFParse({ data });
    const result = await parser.getText();
    await parser.destroy();
    const text = result.text?.trim();
    if (!text) {
        return { entityId, entityType: 'note', status: 'error', error: 'PDF contains no extractable text' };
    }
    const entityType = (options?.entityType ?? 'note');
    const now = new Date().toISOString();
    const userHandle = options?.user
        ? (options.user.email?.split('@')[0] ?? options.user.name ?? 'import')
        : 'import';
    const created = await createWithCollisionRetry(graph, {
        entityType,
        entityId,
        data: {
            entity_type: entityType,
            entity_id: entityId,
            name: deriveImportName(filename, entityId),
            created_at: now,
            updated_at: now,
            created_by: userHandle,
            updated_by: userHandle,
        },
        content: text,
        folderPath: options?.folderPath,
        user: options?.user,
        commitMessage: options?.commitMessage ?? `Import ${entityType}: ${entityId}`,
        skipCommit: options?.skipCommit ?? true,
    });
    return { entityId: created.entityId, entityType, status: 'created', path: created.path };
}
/** Convert kebab-case entity ID to a readable name. */
function formatName(entityId) {
    return entityId
        .replace(/-/g, ' ')
        .replace(/\b\w/g, c => c.toUpperCase());
}
//# sourceMappingURL=import-service.js.map