/**
 * Format Registry — the single source of truth for how Studiograph ingests
 * every file type.
 *
 * Framework-free and dependency-free (sits alongside `validation.ts` /
 * `schema-registry.ts`), so it can be imported by server routes, services,
 * the MCP layer, the CLI, and serialized to the web client over
 * `GET /api/formats`. Every ingestion surface reads from here instead of
 * maintaining its own parallel extension / MIME / accept list — that
 * divergence (a stale `.xlsx`, a half-supported `.svg`, a PDF that becomes
 * an asset on one path and a note on another) is exactly what this registry
 * exists to kill.
 *
 * Each `FormatEntry` declares the full disposition of a format: how it's
 * handled, whether the original is retained, what (if anything) it derives
 * to, how it's indexed for search, and which extractor feeds that index.
 * The fields are consumed incrementally across the phased refactor — P0 wires
 * the routing (`handler`, `category`, `extractor`, `sanitize`) and the
 * searchability contract (`indexAs`); later phases consume `deriveTo` /
 * `derive` (conversions, P2), `extractor` for sidecars/captions (indexing,
 * P3), `mimeTypes` (MCP presign, P4), and the serialized shape (client, P5).
 *
 * "Dependency-free" here means no npm packages / no framework — the Node
 * `path` builtin is fine and shared with the rest of `src/core`.
 */
import { extname } from 'path';
const MB = 1024 * 1024;
/** Matches the 100MB cap enforced inside the asset upload route today. */
const ASSET_MAX_BYTES = 100 * MB;
/** Generous bound for verbatim text imports. Declarative in P0. */
const TEXT_MAX_BYTES = 25 * MB;
/**
 * The canonical format table. Order is not significant for lookup (maps are
 * built below), but it reads top-to-bottom as: direct text → convertibles →
 * image assets → opaque binary assets → bookmarks.
 *
 * The opaque-asset block intentionally enumerates the FULL set the web import
 * dispatcher already accepts as assets today (video / audio / legacy office /
 * design files / archives). P0 is a parity refactor: those formats stay
 * assets, and genuinely-unlisted extensions stay *rejected* (they resolve to
 * `FALLBACK_ASSET_ENTRY`, which the P0 dispatcher refuses). Accepting unknown
 * types as generic assets is a deliberate, separately-shipped behavior change
 * (P0b) — it flips only how the dispatcher treats the fallback, not this table.
 */
const FORMATS = [
    // ── Direct text → entity (verbatim, no LLM) ──────────────────────────────
    {
        extensions: ['.md'],
        mimeTypes: ['text/markdown'],
        category: 'direct-note',
        retainSource: false,
        deriveTo: null,
        derive: 'none',
        indexAs: 'body',
        handler: 'importDirect',
        extractor: null,
        maxBytes: TEXT_MAX_BYTES,
    },
    {
        extensions: ['.txt'],
        mimeTypes: ['text/plain'],
        category: 'direct-note',
        retainSource: false,
        deriveTo: null,
        derive: 'none',
        indexAs: 'body',
        handler: 'importDirect',
        extractor: null,
        maxBytes: TEXT_MAX_BYTES,
    },
    {
        extensions: ['.csv'],
        mimeTypes: ['text/csv'],
        category: 'direct-dataset',
        retainSource: false,
        deriveTo: null,
        derive: 'none',
        indexAs: 'body',
        handler: 'importDirect',
        extractor: null,
        maxBytes: TEXT_MAX_BYTES,
    },
    // ── Asset + optional deferred conversion (entry-primary, D1/D2) ───────────
    {
        extensions: ['.pdf'],
        mimeTypes: ['application/pdf'],
        category: 'asset-convertible',
        retainSource: true,
        deriveTo: 'note',
        derive: 'deferred',
        indexAs: 'sidecar',
        handler: 'uploadAsset',
        extractor: 'pdf',
        maxBytes: ASSET_MAX_BYTES,
    },
    {
        extensions: ['.docx'],
        mimeTypes: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
        category: 'asset-convertible',
        retainSource: true,
        deriveTo: 'note',
        derive: 'deferred',
        indexAs: 'sidecar',
        handler: 'uploadAsset',
        extractor: 'docx',
        maxBytes: ASSET_MAX_BYTES,
        // P2 flipped docx onto the asset path: it now uploads as an `asset` and
        // converts to a note on demand (convert-service), so it's no longer
        // transitional. (svg stays transitional until sanitize-html lands in P3.)
    },
    {
        extensions: ['.xlsx'],
        mimeTypes: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
        category: 'asset-convertible',
        retainSource: true,
        deriveTo: 'dataset',
        derive: 'deferred',
        indexAs: 'sidecar',
        handler: 'uploadAsset',
        extractor: 'xlsx',
        maxBytes: ASSET_MAX_BYTES,
    },
    // ── Image assets (caption + OCR indexed, P3) ──────────────────────────────
    ...imageAssets(
    // Ordered most-common-first (drives the import modal's "View supported
    // files" list, which preserves this order rather than sorting): the
    // formats people actually have, then the newer avif/heic tail.
    { exts: ['.jpg', '.jpeg'], mime: 'image/jpeg' }, { exts: ['.png'], mime: 'image/png' }, { exts: ['.gif'], mime: 'image/gif' }, { exts: ['.webp'], mime: 'image/webp' }, { exts: ['.heic'], mime: 'image/heic' }, { exts: ['.avif'], mime: 'image/avif' }, 
    // SVG is sanitized on ingest where the server holds the bytes (D7).
    // P0 transitional: until `sanitize-html` is wired (P3) it stays on the
    // agent-extraction path and must NOT reach uploadAsset — storing an
    // unsanitized svg reopens the stored-XSS hole the asset route blocks.
    { exts: ['.svg'], mime: 'image/svg+xml', sanitize: 'svg', transitional: 'agent-extract' }),
    // ── Opaque binary assets (filename/metadata only) ─────────────────────────
    // Enumerated to preserve current dispatcher behavior: these all become
    // assets today via ASSET_UPLOAD_EXTENSIONS. None are content-indexed.
    ...opaqueAssets(['.mp4', 'video/mp4'], ['.mov', 'video/quicktime'], ['.webm', 'video/webm'], ['.avi', 'video/x-msvideo'], ['.mkv', 'video/x-matroska'], ['.m4v', 'video/x-m4v'], ['.mp3', 'audio/mpeg'], ['.wav', 'audio/wav'], ['.m4a', 'audio/mp4'], ['.ogg', 'audio/ogg'], ['.flac', 'audio/flac'], ['.aac', 'audio/aac'], ['.xls', 'application/vnd.ms-excel'], ['.numbers', 'application/octet-stream'], ['.tsv', 'text/tab-separated-values'], ['.doc', 'application/msword'], ['.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'], ['.ppt', 'application/vnd.ms-powerpoint'], ['.key', 'application/octet-stream'], ['.pages', 'application/octet-stream'], ['.indd', 'application/octet-stream'], ['.idml', 'application/octet-stream'], ['.sketch', 'application/octet-stream'], ['.psd', 'image/vnd.adobe.photoshop'], ['.ai', 'application/postscript'], ['.fig', 'application/octet-stream'], ['.zip', 'application/zip'], ['.tar', 'application/x-tar'], ['.gz', 'application/gzip'], ['.7z', 'application/x-7z-compressed'], ['.rar', 'application/vnd.rar'], ['.woff2', 'font/woff2'], ['.woff', 'font/woff'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf']),
    // ── Bookmarks (Netscape HTML → reference entities) ────────────────────────
    // Detected by content (`isNetscapeBookmarkFile`), NOT by extension — a
    // `.html` file is not necessarily a bookmark export. The dispatcher's
    // content-sniff + routing lands in P8; until then bookmark exports use the
    // dedicated `POST /api/repos/:repo/import-bookmarks` endpoint and a bare
    // `.html` resolves to the fallback (rejected in P0). This entry carries no
    // extensions so it is reachable only via the content sniff.
    {
        extensions: [],
        mimeTypes: ['text/html'],
        category: 'bookmarks',
        retainSource: false,
        deriveTo: 'reference',
        derive: 'inline',
        indexAs: 'body',
        handler: 'importBookmarks',
        extractor: null,
        maxBytes: TEXT_MAX_BYTES,
    },
];
/** Concise builder for the caption-indexed image-asset block. */
function imageAssets(...specs) {
    return specs.map(s => ({
        extensions: s.exts,
        mimeTypes: [s.mime],
        category: 'asset',
        retainSource: true,
        deriveTo: null,
        derive: 'none',
        indexAs: 'caption',
        handler: 'uploadAsset',
        extractor: 'image-caption',
        maxBytes: ASSET_MAX_BYTES,
        ...(s.sanitize ? { sanitize: s.sanitize } : {}),
        ...(s.transitional ? { transitional: s.transitional } : {}),
    }));
}
/** Concise builder for the opaque-asset block. */
function opaqueAssets(...pairs) {
    return pairs.map(([ext, mime]) => ({
        extensions: [ext],
        mimeTypes: [mime],
        category: 'asset',
        retainSource: true,
        deriveTo: null,
        derive: 'none',
        indexAs: 'none',
        handler: 'uploadAsset',
        extractor: null,
        maxBytes: ASSET_MAX_BYTES,
    }));
}
/**
 * The disposition for any file the registry doesn't recognize: a generic
 * opaque asset, never content-indexed. `resolveFormat` returns this exact
 * (frozen) reference for unknown types so callers can identity-check it —
 * the P0 dispatcher rejects on `entry === FALLBACK_ASSET_ENTRY` to preserve
 * today's reject behavior; P0b removes that guard to accept unknowns
 * leniently (the only behavior change between the two phases).
 */
export const FALLBACK_ASSET_ENTRY = Object.freeze({
    extensions: [],
    mimeTypes: ['application/octet-stream'],
    category: 'asset',
    retainSource: true,
    deriveTo: null,
    derive: 'none',
    indexAs: 'none',
    handler: 'uploadAsset',
    extractor: null,
    maxBytes: ASSET_MAX_BYTES,
});
// ── Lookup maps ─────────────────────────────────────────────────────────────
const byExtension = new Map();
const byMime = new Map();
for (const entry of FORMATS) {
    for (const ext of entry.extensions)
        byExtension.set(ext.toLowerCase(), entry);
    for (const mime of entry.mimeTypes) {
        // First entry wins on a shared MIME (e.g. text/csv) so lookupByMime is
        // deterministic; extension lookup is the primary path anyway.
        if (!byMime.has(mime.toLowerCase()))
            byMime.set(mime.toLowerCase(), entry);
    }
}
/**
 * Extract the lowercased, dot-prefixed extension from a filename. Delegates
 * to Node's `path.extname` (so dotfiles, multi-dot names, and trailing dots
 * are handled the way the rest of the codebase handles them) and lowercases
 * to match the registry's lowercased lookup keys.
 */
function extensionOf(filename) {
    return extname(filename).toLowerCase();
}
// ── Public API ──────────────────────────────────────────────────────────────
/** Look up a format by extension (dot-prefixed or bare). */
export function lookupByExtension(ext) {
    const norm = ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`;
    return byExtension.get(norm);
}
/** Look up a format by MIME type. */
export function lookupByMime(mime) {
    return byMime.get(mime.toLowerCase());
}
/**
 * Resolve the disposition for a file. Never returns undefined — unknown
 * types resolve to `FALLBACK_ASSET_ENTRY` (identity-checkable). Extension is
 * the primary key; `mime` is a fallback when the filename carries no usable
 * extension. Bookmark detection is by content (`isNetscapeBookmarkFile`),
 * NOT handled here.
 */
export function resolveFormat(filename, mime) {
    const byExt = byExtension.get(extensionOf(filename));
    if (byExt)
        return byExt;
    if (mime) {
        const m = byMime.get(mime.toLowerCase());
        if (m)
            return m;
    }
    return FALLBACK_ASSET_ENTRY;
}
/** A format is searchable when it declares any index extraction. */
export function isIndexable(entry) {
    return entry.indexAs !== 'none';
}
function isFontAsset(entry) {
    return entry.category === 'asset'
        && entry.extractor === null
        && entry.sanitize === undefined
        && entry.transitional === undefined
        && entry.mimeTypes.some(mime => mime.startsWith('font/'));
}
/**
 * Extensions whose bytes are stored as a binary asset (category `asset` or
 * `asset-convertible`), plus `.csv` — which is a direct-dataset on the web
 * import path but has historically been accepted by `uploadAsset` too (the
 * agent `import_file` tool stores a dropped `.csv` as an opaque asset). Kept
 * in the set so `isAssetUploadExtension` preserves that behavior exactly.
 * Returned WITHOUT the leading dot to match the legacy `base.ts` set.
 */
export function assetUploadExtensions() {
    const out = new Set();
    for (const entry of FORMATS) {
        // Transitional formats (docx/svg in P0) target uploadAsset but still
        // route through the legacy agent path, so they are NOT yet asset-upload
        // extensions — excluding them keeps `isAssetUploadExtension` byte-for-byte
        // identical to today's behavior.
        if (entry.transitional)
            continue;
        if (entry.category === 'asset' || entry.category === 'asset-convertible') {
            for (const ext of entry.extensions)
                out.add(ext.replace(/^\./, ''));
        }
    }
    out.add('csv');
    return out;
}
/**
 * Extensions the `import-service` text path participates in — verbatim
 * direct-write formats, anything with a text/caption extractor, and the
 * sanitized SVG. Used to derive `import-service`'s `isSupportedFile`.
 *
 * This is a (small) superset of the legacy literal — it additionally
 * includes `.xlsx`, `.avif`, `.heic` — but every one of those extra
 * extensions is an asset-upload extension, so it is caught by
 * `isAssetUploadExtension` *before* `isSupportedFile` is ever consulted in
 * the only live caller (the agent `import_file` tool). The observable
 * behavior is therefore identical to the legacy set. Dot-prefixed.
 */
export function importServiceExtensions() {
    const out = new Set();
    for (const entry of FORMATS) {
        if (entry.handler === 'importDirect' || entry.extractor !== null || entry.sanitize === 'svg') {
            for (const ext of entry.extensions)
                out.add(ext.toLowerCase());
        }
    }
    return out;
}
/**
 * Per-ingress capability (P4, Codex drift-guard). Each ingress path has a
 * different trust model, so "which formats may travel this path" is a property
 * of the format + the path, expressed here in the registry rather than as a
 * special-case in the presign/upload code:
 *
 *  - `presign`   — MCP pre-signed PUT straight to R2. The app NEVER holds the
 *    bytes, so it cannot sanitize them. Only formats that are safe as raw bytes
 *    are eligible: image assets with no `sanitize` requirement and font assets.
 *    This is what keeps SVG (sanitize: 'svg') and every doc/video/opaque type
 *    OFF the presign path without a hardcoded list.
 *  - `inline`    — MCP base64 bytes through the app (V3) OR web multipart. The
 *    app holds the bytes, so it CAN sanitize — SVG is allowed here (sanitize
 *    runs before store). Any asset the upload pipeline accepts is inline-able.
 *  - `multipart` — alias of `inline` for the web upload route's perspective.
 *
 * A format is presign-safe iff: it's an image asset or font asset, needs no
 * sanitization, and isn't transitional. Images cover png/jpeg/gif/webp/avif/heic;
 * fonts cover woff2/woff/ttf/otf.
 */
export function isPresignSafe(entry) {
    return (entry.category === 'asset'
        && entry.extractor === 'image-caption' // image, not opaque/doc/video
        && entry.sanitize === undefined // raw bytes are safe as-is
        && entry.transitional === undefined // real handler is wired
    ) || isFontAsset(entry);
}
/** A format may travel the inline/multipart path iff the upload pipeline takes
 *  it (any asset, including ones that need sanitizing — the app holds the bytes
 *  and sanitizes before store). Excludes still-transitional formats. */
export function isInlineUploadAllowed(entry) {
    return (entry.category === 'asset' || entry.category === 'asset-convertible')
        && entry.transitional === undefined;
}
/**
 * MIME allowlist for the MCP PRESIGN path (Content-Type gate baked into the PUT
 * signature). Derived from `isPresignSafe` — NOT every asset MIME. The presign
 * service + the MCP tool's mime enum both read this so they can't drift.
 */
export function presignMimeAllowlist() {
    const out = new Set();
    for (const entry of FORMATS) {
        if (isPresignSafe(entry)) {
            for (const mime of entry.mimeTypes)
                out.add(mime);
        }
    }
    return [...out];
}
/**
 * MIME allowlist for the INLINE-bytes / multipart path (V3 — the app holds the
 * bytes and can sanitize). Broader than presign: includes SVG and the
 * convertible docs. Drives the V3 `upload_asset_inline` MCP tool's gate.
 */
export function inlineUploadMimeAllowlist() {
    const out = new Set();
    for (const entry of FORMATS) {
        if (isInlineUploadAllowed(entry)) {
            for (const mime of entry.mimeTypes)
                out.add(mime);
        }
    }
    return [...out];
}
/**
 * The HTML `accept` attribute value covering every ingestible format
 * (extensions + MIME types). The single source the client's fragmented
 * accept lists collapse onto (P5).
 */
export function acceptAttribute() {
    const tokens = new Set();
    for (const entry of FORMATS) {
        for (const ext of entry.extensions)
            tokens.add(ext);
        for (const mime of entry.mimeTypes)
            tokens.add(mime);
    }
    return [...tokens].join(',');
}
/**
 * JSON-safe projection of the registry for `GET /api/formats`. The client
 * builds its accept lists, disposition matrix, and supported-types copy from
 * this — `supported-file-types.ts` becomes a derived view (P5).
 */
export function serializeForClient() {
    return {
        formats: FORMATS.map(e => ({ ...e, extensions: [...e.extensions], mimeTypes: [...e.mimeTypes] })),
        accept: acceptAttribute(),
    };
}
/** All registered entries (read-only view). */
export function allFormats() {
    return FORMATS;
}
//# sourceMappingURL=format-registry.js.map