/**
 * Asset service for Studiograph
 *
 * Manages all media assets (images, videos, audio, documents)
 * with pluggable storage backends (local filesystem or R2)
 */
import { validateFilename, detectContentType, buildAssetKey, buildAssetPrefix, } from './base.js';
import { LocalAssetBackend } from './local.js';
import { R2AssetBackend } from './r2.js';
import { encodeAssetFilename } from '../../core/artifact-asset-urls.js';
export * from './base.js';
export class AssetService {
    backend;
    storage;
    constructor(config) {
        // Initialize appropriate backend
        if (config.storage === 'local') {
            const localPath = config.localPath || '.studiograph/assets';
            this.backend = new LocalAssetBackend(localPath);
            this.storage = 'local';
        }
        else if (config.storage === 'r2') {
            if (!config.r2Config) {
                throw new Error('R2 config is required when storage is set to r2');
            }
            this.backend = new R2AssetBackend(config.r2Config);
            this.storage = 'r2';
        }
        else {
            throw new Error(`Unknown storage backend: ${config.storage}`);
        }
    }
    /** Which backend this service is bound to. Routes that need different
     *  serving strategies per backend (e.g. stream local files vs. 302 to
     *  pre-signed URL for R2) branch on this. */
    getStorage() {
        return this.storage;
    }
    /** Whether the backend can mint short-lived pre-signed download URLs.
     *  True for R2; false for local (where the HTTP server streams files
     *  directly). */
    supportsSignedUrls() {
        return typeof this.backend.getSignedUrl === 'function';
    }
    /** Whether the backend supports the full pre-signed PUT + HEAD + copy
     *  lifecycle needed for the MCP presign flow. Tied to all three because a
     *  backend that signs PUT but can't HEAD-verify the upload would break
     *  the finalize contract. R2 supports all three; local supports none. */
    supportsPresignedUploads() {
        return (typeof this.backend.getSignedPutUrl === 'function' &&
            typeof this.backend.head === 'function' &&
            typeof this.backend.copy === 'function');
    }
    /** Generate a short-lived pre-signed download URL for an asset.
     *  Throws if the backend doesn't support pre-signed URLs (local).
     *  Default expiry is 4 hours (14400s) — see R2AssetBackend.getSignedUrl
     *  for the rationale. `method` selects the HTTP verb the URL is signed
     *  against — HEAD requests (link unfurls, OpenGraph) need a HEAD-signed
     *  URL because pre-signed URLs are method-bound. */
    async getSignedUrl(entityType, entityId, filename, keyPrefix, expiresIn = 14400, method = 'GET') {
        if (!this.backend.getSignedUrl) {
            throw new Error('Pre-signed URLs are not supported by the local asset backend');
        }
        // Validate every key component, not just filename. Without this, a
        // caller passing entityType='..' or entityId='../' could mint a
        // signed URL for any object in the bucket — including non-asset
        // prefixes (fonts, tokens, theme blobs). Today the only caller
        // is the asset route which feeds Fastify path segments (so `/` is
        // already blocked), but the AssetService is a public surface and
        // future callers shouldn't have to know about the route's quirks.
        for (const part of [entityType, entityId, filename]) {
            if (!part || part.includes('/') || part.includes('\\') || part.includes('..')) {
                throw new Error('Asset key components cannot contain path separators or traversal sequences');
            }
        }
        validateFilename(filename);
        const key = buildAssetKey(entityType, entityId, filename, keyPrefix);
        return await this.backend.getSignedUrl(key, expiresIn, method);
    }
    // ── Immutable-key (assetId) surface ──────────────────────────────────────
    //
    // New scheme: assets are keyed `{assetId}/{filename}` where assetId is an
    // immutable UUID. The owning entity's (type,id) is no longer in the key —
    // ownership/authz lives in AssetIndexService. The legacy `(type,id,filename)`
    // methods above stay for the dual-read cutover window.
    /** Reject key segments that could escape the prefix. */
    assertSafeSegment(seg) {
        if (!seg || seg.includes('/') || seg.includes('\\') || seg.includes('..')) {
            throw new Error('Asset key components cannot contain path separators or traversal sequences');
        }
    }
    /** Upload bytes under an immutable assetId key. Caller records the index row. */
    async uploadByAssetId(assetId, filename, buffer) {
        this.assertSafeSegment(assetId);
        validateFilename(filename);
        if (!buffer || buffer.length === 0)
            throw new Error('Buffer cannot be empty');
        const maxSize = 100 * 1024 * 1024;
        if (buffer.length > maxSize)
            throw new Error(`File too large (max ${maxSize / 1024 / 1024}MB)`);
        const contentType = detectContentType(filename);
        return await this.backend.upload(`${assetId}/${filename}`, buffer, contentType);
    }
    async getSignedUrlByAssetId(assetId, filename, expiresIn = 14400, method = 'GET') {
        if (!this.backend.getSignedUrl) {
            throw new Error('Pre-signed URLs are not supported by the local asset backend');
        }
        this.assertSafeSegment(assetId);
        validateFilename(filename);
        return await this.backend.getSignedUrl(`${assetId}/${filename}`, expiresIn, method);
    }
    async deleteByAssetId(assetId, filename) {
        this.assertSafeSegment(assetId);
        validateFilename(filename);
        return await this.backend.delete(`${assetId}/${filename}`);
    }
    async existsByAssetId(assetId, filename) {
        this.assertSafeSegment(assetId);
        validateFilename(filename);
        return await this.backend.exists(`${assetId}/${filename}`);
    }
    // ── Pending-upload (MCP presign) surface ────────────────────────────────
    //
    // Quarantine prefix: `pending/{assetId}/{filename}`. The agent PUTs bytes
    // here directly via a pre-signed URL; finalize HEADs + copies into the
    // canonical `{assetId}/{filename}` keyspace + deletes pending. An R2
    // lifecycle rule on `pending/` is the backstop that cleans up orphans the
    // agent never finalized (see docs/r2-lifecycle.md). All methods throw if
    // the backend doesn't support pre-signed uploads (local FS).
    pendingKey(assetId, filename) {
        this.assertSafeSegment(assetId);
        validateFilename(filename);
        return `pending/${assetId}/${filename}`;
    }
    canonicalKey(assetId, filename) {
        this.assertSafeSegment(assetId);
        validateFilename(filename);
        return `${assetId}/${filename}`;
    }
    async signPendingPutUrl(assetId, filename, contentType, expiresIn = 900) {
        if (!this.backend.getSignedPutUrl) {
            throw new Error('Pre-signed uploads are not supported by the local asset backend');
        }
        return await this.backend.getSignedPutUrl(this.pendingKey(assetId, filename), contentType, expiresIn);
    }
    /** HEAD the pending blob. Returns null when the agent never PUT (or the
     *  TTL already expired). */
    async headPending(assetId, filename) {
        if (!this.backend.head) {
            throw new Error('HEAD is not supported by the local asset backend');
        }
        return await this.backend.head(this.pendingKey(assetId, filename));
    }
    /** Server-side copy pending → canonical (R2's CopyObject — no download). */
    async copyPendingToCanonical(assetId, filename) {
        return await this.backend.copy(this.pendingKey(assetId, filename), this.canonicalKey(assetId, filename));
    }
    async deletePending(assetId, filename) {
        return await this.backend.delete(this.pendingKey(assetId, filename));
    }
    /** Bucket lifecycle rules summary for the MCP presign flow's boot-time
     *  backstop. Returns `null` when the backend has no lifecycle support
     *  (local) or when the bucket has no rules configured at all. Throws on
     *  permission / network errors so the caller can decide whether to
     *  degrade or propagate. */
    async getBucketLifecycle() {
        if (!this.backend.getBucketLifecycle)
            return null;
        return await this.backend.getBucketLifecycle();
    }
    /** Download an object's bytes by raw key. Validates every path segment —
     *  the key is derived from a stored `asset_url`, so a `..` segment must
     *  never reach the backend's path join (arbitrary-file read). */
    async download(key) {
        for (const seg of key.split('/')) {
            this.assertSafeSegment(seg);
        }
        return await this.backend.download(key);
    }
    /** Upload an object's bytes by raw key (non-catalog keyspaces — e.g. the chat
     *  conversation-scratch images, alongside the existing `pending` keyspace).
     *  Validates every path segment. Does NOT create an asset-index row. */
    async uploadByKey(key, buffer, contentType) {
        for (const seg of key.split('/')) {
            this.assertSafeSegment(seg);
        }
        return await this.backend.upload(key, buffer, contentType);
    }
    /** Delete an object's bytes by raw key (non-catalog keyspaces). Validates
     *  every path segment. No-op-safe at the backend level. */
    async deleteByKey(key) {
        for (const seg of key.split('/')) {
            this.assertSafeSegment(seg);
        }
        return await this.backend.delete(key);
    }
    /** Pre-signed download URL for a raw key (R2 only; local throws). Validates
     *  every path segment. Used to 302-redirect non-catalog objects (scratch
     *  images) the same way catalog assets are served. */
    async getSignedUrlForKey(key, method = 'GET', expiresIn = 14400) {
        if (!this.backend.getSignedUrl) {
            throw new Error('Pre-signed URLs are not supported by the local asset backend');
        }
        for (const seg of key.split('/')) {
            this.assertSafeSegment(seg);
        }
        return await this.backend.getSignedUrl(key, expiresIn, method);
    }
    /** Server-side copy between raw keys (migration + transfer-copy). Validates
     *  every path segment of both keys — the migration derives srcKey from
     *  user-editable body URLs, so a `..` in a key segment must never reach the
     *  backend's path join (arbitrary-file read/write). */
    async copy(srcKey, destKey) {
        for (const seg of [...srcKey.split('/'), ...destKey.split('/')]) {
            this.assertSafeSegment(seg);
        }
        return await this.backend.copy(srcKey, destKey);
    }
    /**
     * Upload asset for an entity
     */
    async upload(entityType, entityId, filename, buffer, keyPrefix) {
        // Validate filename
        validateFilename(filename);
        // Validate buffer
        if (!buffer || buffer.length === 0) {
            throw new Error('Buffer cannot be empty');
        }
        // Check file size (max 100MB)
        const maxSize = 100 * 1024 * 1024; // 100MB
        if (buffer.length > maxSize) {
            throw new Error(`File too large (max ${maxSize / 1024 / 1024}MB)`);
        }
        // Detect content type
        const contentType = detectContentType(filename);
        // Build storage key
        const key = buildAssetKey(entityType, entityId, filename, keyPrefix);
        // Upload via backend
        return await this.backend.upload(key, buffer, contentType);
    }
    /**
     * List all assets for an entity
     */
    async list(entityType, entityId, keyPrefix) {
        const prefix = buildAssetPrefix(entityType, entityId, keyPrefix);
        return await this.backend.list(prefix);
    }
    /**
     * List every asset in the backend in one round-trip, with their
     * (entityType, entityId) origins parsed from the storage key.
     */
    async listAll() {
        return await this.backend.listAll();
    }
    /**
     * Delete a single asset
     */
    async delete(entityType, entityId, filename, keyPrefix) {
        validateFilename(filename);
        const key = buildAssetKey(entityType, entityId, filename, keyPrefix);
        return await this.backend.delete(key);
    }
    /**
     * Delete all assets for an entity (when entity is deleted)
     */
    async deleteAll(entityType, entityId, keyPrefix) {
        const prefix = buildAssetPrefix(entityType, entityId, keyPrefix);
        return await this.backend.deleteAll(prefix);
    }
    /**
     * Check if an asset exists
     */
    async exists(entityType, entityId, filename, keyPrefix) {
        validateFilename(filename);
        const key = buildAssetKey(entityType, entityId, filename, keyPrefix);
        return await this.backend.exists(key);
    }
    /**
     * Get asset info without downloading
     */
    async getInfo(entityType, entityId, filename, keyPrefix) {
        const assets = await this.list(entityType, entityId, keyPrefix);
        return assets.find(a => a.filename === filename) || null;
    }
}
/**
 * Convert an `AssetInfo` returned by a backend into the URL stored in
 * an entity's `asset_url` (or used as the `src` of an inline embed).
 *
 * Stage 2 of the asset architecture (see docs/asset-architecture.md):
 * always emit the proxied `/api/assets/{type}/{id}/{file}` path, regardless
 * of backend. The route is the single chokepoint that enforces per-repo
 * authz — local files are streamed directly, R2-backed assets get a 302
 * to a short-lived pre-signed URL. New uploads never persist absolute
 * R2 URLs in frontmatter; the migration CLI rewrites legacy ones.
 *
 * Centralised here because three different call sites used to inline this
 * ternary, and getting it wrong silently breaks image rendering.
 */
export function publicUrlFor(_asset, entityType, entityId) {
    return `/api/assets/${entityType}/${entityId}/${encodeAssetFilename(_asset.filename)}`;
}
/**
 * Immutable-key public URL: `/api/assets/{assetId}/{filename}`. Two path
 * segments after `/api/assets/` (vs three for the legacy form), so the new
 * and legacy serving routes disambiguate by arity during cutover.
 */
export function publicUrlForAssetId(assetId, filename) {
    return `/api/assets/${assetId}/${encodeAssetFilename(filename)}`;
}
// The percent-encoder for asset-URL filename segments lives with its parse
// counterpart in core/artifact-asset-urls (encode on emit, decode on parse —
// one owner for the identity contract); re-exported here for the existing
// server-side import sites.
export { encodeAssetFilename };
//# sourceMappingURL=index.js.map