/**
 * Shared repo-asset listing.
 *
 * Builds the list of assets owned by entries in a single repo, merging the two
 * storage schemes (legacy `{type}/{id}/{filename}` blobs surfaced by the
 * backend's listAll, and immutable-keyed assets tracked only in the ownership
 * index). Extracted from the asset-api route so the HTTP endpoints and the MCP
 * `list_assets` tool return an identical shape — including a render-safe
 * `embed` string callers can paste straight into an entry body.
 */
import { publicUrlFor, publicUrlForAssetId, getMediaType } from './assets/index.js';
import { assetEmbed } from '../core/embeds.js';
import { readFileSync } from 'fs';
/** Owner kinds that mean "free-standing" — the asset is owned by a folder or
 *  the workspace, not by an entity, so it has no owning markdown file and is
 *  deleted/managed by its assetId rather than an entity coordinate. */
export const FREE_STANDING_OWNER_TYPES = new Set(['folder', 'workspace']);
export function isFreeStandingOwner(ownerType) {
    return FREE_STANDING_OWNER_TYPES.has(ownerType);
}
/** Map an immutable-index row → RepoAsset, shared by the per-repo and
 *  workspace-wide listings (both merge index rows the same way). `meta` is the
 *  owning entity's resolved name/createdAt (null for free-standing rows, which
 *  have no owning entity). Free-standing rows label by the asset's own name. */
function indexRowToRepoAsset(row, meta) {
    const freeStanding = isFreeStandingOwner(row.ownerType);
    const url = publicUrlForAssetId(row.assetId, row.filename);
    return {
        filename: row.filename,
        // Real byte size lives in the index (`size` column, recorded at upload);
        // fall back to 0 for legacy rows written before the column existed.
        size: row.size ?? 0,
        contentType: row.contentType,
        url,
        embed: assetEmbed(row.filename, url),
        assetId: row.assetId,
        ownerType: row.ownerType,
        entityType: row.ownerType,
        entityId: row.ownerId,
        // Free-standing: the asset's own name (or filename). Entity-owned: the
        // owning entity's name, falling back to its id.
        entityName: freeStanding ? (row.name ?? row.filename) : (meta.name ?? row.ownerId),
        // Derived (not the stored media_type column) so category-rule refinements
        // re-bucket existing rows at read time — no reindex/migration. The column
        // stays for internal queries (caption sweep's image check).
        mediaType: getMediaType(row.contentType, row.filename),
        // Prefer the entity's created_at for a meaningful "created" date; fall back
        // to the index row's own timestamp when the entity has none.
        createdAt: meta.createdAt ?? row.createdAt ?? null,
    };
}
/**
 * Read the owning entity's `name:` and `created_at:` from a markdown file's
 * YAML frontmatter without a full YAML parse. Lets the asset list label assets
 * by entity name and sort by creation date without a markdownService.parseFile()
 * per entity. Either field may be null if absent.
 */
function matchScalar(block, field) {
    const m = block.match(new RegExp(`^${field}:\\s*(?:'([^']*)'|"([^"]*)"|(.+?))\\s*$`, 'm'));
    if (!m)
        return null;
    return (m[1] ?? m[2] ?? m[3]).trim() || null;
}
function extractEntityMeta(mdPath) {
    try {
        const raw = readFileSync(mdPath, 'utf-8');
        // Frontmatter is always at the top; don't read past ~2KB.
        const head = raw.slice(0, 2048);
        if (!head.startsWith('---'))
            return { name: null, createdAt: null };
        const end = head.indexOf('\n---', 3);
        const block = end === -1 ? head : head.slice(0, end);
        return { name: matchScalar(block, 'name'), createdAt: matchScalar(block, 'created_at') };
    }
    catch {
        return { name: null, createdAt: null };
    }
}
/**
 * Build the asset list for a single repo. Returns [] if the repo's graph can't
 * be loaded (right for the bulk endpoint; single-repo callers should check
 * repo existence themselves first if they want a 404).
 *
 * Algorithm (works for both local and R2 backends):
 *   1. One listAllAssets() call — single filesystem walk or one paginated S3
 *      ListObjectsV2 request. No HEAD round-trips, no markdown parsing.
 *   2. Resolve each asset's entity markdown path via the graph's file index
 *      (not a `{plural}/{id}.md` guess, which misses root/nested entries).
 *   3. Merge in immutable-keyed assets from the ownership index — these live
 *      under an `{assetId}/` prefix the backend's listAll can't see.
 */
export async function listRepoAssets(repo, workspaceManager) {
    let graph;
    try {
        graph = workspaceManager.getGraph(repo);
    }
    catch {
        return [];
    }
    let allAssets;
    try {
        allAssets = await graph.listAllAssets();
    }
    catch {
        return [];
    }
    const metaCache = new Map();
    const result = [];
    for (const asset of allAssets) {
        const mdPath = graph.getEntityFilePath(asset.entityType, asset.entityId);
        if (!mdPath)
            continue;
        const key = `${asset.entityType}/${asset.entityId}`;
        let meta = metaCache.get(key);
        if (meta === undefined) {
            meta = extractEntityMeta(mdPath);
            metaCache.set(key, meta);
        }
        const url = publicUrlFor(asset, asset.entityType, asset.entityId);
        result.push({
            filename: asset.filename,
            size: asset.size,
            contentType: asset.contentType,
            url,
            embed: assetEmbed(asset.filename, url),
            assetId: null,
            ownerType: asset.entityType,
            entityType: asset.entityType,
            entityId: asset.entityId,
            entityName: meta.name ?? asset.entityId,
            mediaType: asset.mediaType,
            createdAt: meta.createdAt,
        });
    }
    // Immutable-keyed assets live in the ownership index, not under a
    // {type}/{id} key prefix, so backend.listAll() can't see them. Merge them in
    // from assets.db. (During the migration cutover — after migrate, before
    // old-blob cleanup — an asset can appear via both paths; that transient
    // duplicate clears when the legacy blobs are deleted.)
    const index = workspaceManager.getAssetIndex?.();
    if (index) {
        for (const row of index.listByRepo(repo)) {
            // Free-standing assets (owned by a folder/workspace, not an entity) have
            // no owning markdown file: don't resolve one, and label them by the
            // asset's own name rather than a non-existent owning entity.
            const freeStanding = isFreeStandingOwner(row.ownerType);
            const mdPath = freeStanding
                ? null
                : graph.getEntityFilePath(row.ownerType, row.ownerId);
            const key = `${row.ownerType}/${row.ownerId}`;
            let meta = metaCache.get(key);
            if (meta === undefined) {
                meta = mdPath ? extractEntityMeta(mdPath) : { name: null, createdAt: null };
                metaCache.set(key, meta);
            }
            result.push(indexRowToRepoAsset(row, meta));
        }
    }
    return result;
}
/**
 * Process-lifetime cache of the one slow R2/bucket walk.
 *
 * Transitional bridge, NOT permanent infrastructure. The asset backend is
 * workspace-global and `listAllAssets()` enumerates the WHOLE bucket via
 * sequential paginated `ListObjectsV2` — on a large bucket that's several
 * network round-trips (measured ~7-8s here), and it's the entire reason the
 * asset-manager cold load was slow. The manager is now driven from the LOCAL
 * ownership index (`assets.db`, ~14ms); this walk exists only to also surface
 * un-migrated *legacy* blobs (`{type}/{id}/{filename}`) that predate the
 * immutable-key scheme and so have no index row.
 *
 * New uploads are always immutable-keyed (they get an index row), so the set of
 * legacy blobs can only SHRINK — it never grows. That makes a once-per-process
 * cache correct: re-walking gains nothing. When the separate legacy-blob
 * cleanup finishes, the walk returns no stragglers and this code path becomes
 * an inert no-op that can be deleted outright. `clearLegacyBucketCache()` exists
 * for tests and for an explicit refresh after a bulk cleanup.
 */
// Keyed by workspace path so a multi-workspace process never crosses snapshots.
// Only SUCCESSFUL walks are cached — a transient backend/list failure must retry
// on the next call, not hide every legacy asset until restart (codex P2).
const legacyBucketCache = new Map();
/** Drop the cached legacy-bucket walk(s) (tests; post-delete/cleanup refresh). */
export function clearLegacyBucketCache() {
    legacyBucketCache.clear();
}
async function getLegacyBucketSnapshot(workspaceKey, graph) {
    const cached = legacyBucketCache.get(workspaceKey);
    if (cached)
        return cached;
    let assets;
    try {
        assets = await graph.listAllAssets();
    }
    catch {
        return [];
    } // transient/no-backend — return empty WITHOUT caching so the next call retries
    legacyBucketCache.set(workspaceKey, assets); // cache only on success (an empty bucket is a valid success)
    return assets;
}
/**
 * List assets across MANY repos for the workspace-wide manager — index-first.
 *
 * The fast path is the local ownership index (`assets.db`): one cheap scan per
 * repo yields every immutable-keyed asset with no network I/O. On top of that we
 * fold in any surviving *legacy* blobs via the cached bucket walk above (see
 * `getLegacyBucketSnapshot`) so un-migrated assets still appear. Dedup prefers
 * the immutable entry, so a legacy blob that has already been migrated collapses
 * into its index row rather than double-listing.
 *
 * A legacy blob whose owning entity exists in K accessible repos surfaces under
 * each of those K (entity ids can — rarely — collide across folders), matching
 * the old per-repo behaviour.
 */
export async function listWorkspaceAssets(repos, workspaceManager) {
    // Resolve each repo's graph once; skip repos whose graph can't be loaded
    // (matches listRepoAssets' fail-soft posture for the bulk path).
    const graphs = new Map();
    for (const repo of repos) {
        try {
            graphs.set(repo, workspaceManager.getGraph(repo));
        }
        catch { /* unreadable repo — omit */ }
    }
    if (graphs.size === 0)
        return [];
    // Frontmatter (name + created_at) read at most once per (repo,type,id).
    const metaCache = new Map();
    const metaFor = (repo, graph, type, id) => {
        const key = `${repo}/${type}/${id}`;
        let meta = metaCache.get(key);
        if (meta === undefined) {
            const mdPath = graph.getEntityFilePath(type, id);
            meta = mdPath ? extractEntityMeta(mdPath) : { name: null, createdAt: null };
            metaCache.set(key, meta);
        }
        return meta;
    };
    // Dedup per repo, preferring the immutable-index entry.
    const byKey = new Map();
    const add = (repo, asset) => {
        const key = `${repo}/${asset.entityType}/${asset.entityId}/${asset.filename}`;
        const existing = byKey.get(key);
        if (!existing || (existing.assetId == null && asset.assetId != null))
            byKey.set(key, asset);
    };
    // ── Pass 1 (fast path): immutable-keyed assets from the local ownership
    // index, per repo. No network I/O. Added first so dedup prefers them. ──
    const index = workspaceManager.getAssetIndex?.();
    if (index) {
        for (const [repo, graph] of graphs) {
            for (const row of index.listByRepo(repo)) {
                // Free-standing assets (folder/workspace-owned) have no owning entity —
                // skip entity-meta resolution; the shared mapper labels them by name.
                const meta = isFreeStandingOwner(row.ownerType)
                    ? { name: null, createdAt: null }
                    : metaFor(repo, graph, row.ownerType, row.ownerId);
                add(repo, { ...indexRowToRepoAsset(row, meta), repo });
            }
        }
    }
    // ── Pass 2 (transitional): surviving legacy {type}/{id}/{filename} blobs ──
    // from the cached bucket snapshot. Already-migrated blobs collapse into the
    // Pass-1 index entry; only un-migrated stragglers survive `add()`.
    const anyGraph = graphs.values().next().value;
    const bucket = await getLegacyBucketSnapshot(workspaceManager.getWorkspacePath(), anyGraph);
    if (bucket.length > 0) {
        // FAIL CLOSED on ambiguous legacy blobs (codex P1). A legacy key is
        // {type}/{id}/{filename} with NO repo identity, so a blob whose (type/id)
        // exists in more than one repo can't be safely attributed — surfacing it
        // under an accessible repo would leak an inaccessible folder's blob when a
        // slug collides (e.g. a private folder's `note/foo` vs an accessible
        // `note/foo`). Build ownership across ALL repos (incl. inaccessible) and
        // only surface a blob when EXACTLY ONE repo owns its (type/id) AND that
        // repo is accessible. Immutable assets (Pass 1) are unaffected — they carry
        // their owning repo in the index.
        const accessibleSet = new Set(graphs.keys());
        const allOwners = new Map();
        for (const cfg of workspaceManager.getAllRepoConfigs()) {
            let g;
            try {
                g = workspaceManager.getGraph(cfg.name);
            }
            catch {
                continue;
            }
            for (const { entityType, id } of g.listEntityIds()) {
                const k = `${entityType}/${id}`;
                let set = allOwners.get(k);
                if (!set)
                    allOwners.set(k, (set = new Set()));
                set.add(cfg.name);
            }
        }
        for (const asset of bucket) {
            const owners = allOwners.get(`${asset.entityType}/${asset.entityId}`);
            if (!owners || owners.size !== 1)
                continue; // unknown or ambiguous → fail closed
            const repo = owners.values().next().value;
            if (!accessibleSet.has(repo))
                continue; // sole owner is an inaccessible folder
            const meta = metaFor(repo, graphs.get(repo), asset.entityType, asset.entityId);
            const url = publicUrlFor(asset, asset.entityType, asset.entityId);
            add(repo, {
                filename: asset.filename,
                size: asset.size,
                contentType: asset.contentType,
                url,
                embed: assetEmbed(asset.filename, url),
                assetId: null,
                ownerType: asset.entityType,
                entityType: asset.entityType,
                entityId: asset.entityId,
                entityName: meta.name ?? asset.entityId,
                mediaType: asset.mediaType,
                createdAt: meta.createdAt,
                repo,
            });
        }
    }
    return [...byKey.values()];
}
//# sourceMappingURL=asset-listing.js.map