/**
 * MCP pre-signed asset upload — STEP 3 of 3.
 *
 * The agent has PUT bytes to the pre-signed URL minted by presign; this
 * verifies the upload landed, validates content-type + size at the wire,
 * promotes the blob from `pending/` to canonical keyspace, creates the
 * asset entity, and writes the ownership index row.
 *
 * Layered guarantees (every one mapped to a plan decision):
 *  - Tamper-proof (T2):    verifyAssetUploadToken throws TokenInvalidError on
 *                           tamper / wrong-secret / expiry.
 *  - Identity-pin (R1):    String(user.id) === payload.uploaderUserId.
 *                           Leaked token cannot be replayed by another
 *                           folder-writer (and workspace owner is NOT exempt).
 *  - Idempotent (plan):    assetIndex.get(assetId) → existing row's URL.
 *                           Safe retries on transient network errors at the
 *                           agent's end.
 *  - Authz freshness:      Re-check canWriteRepo. 15-min token outlives a
 *                           folder-access revoke.
 *  - Wire-level validation: HEAD pending and assert stored Content-Type
 *                           matches the token's `mime`, and Content-Length
 *                           ≤ 20MB. On mismatch, delete pending immediately
 *                           so the user doesn't get charged for it.
 *  - Atomic-ish promote:   r2.copy (pending → canonical) is server-side.
 *  - Compensating delete (R5): if entity-create OR index.put throws AFTER
 *                           the canonical copy succeeded, delete the
 *                           canonical blob so we don't leave a body-less
 *                           asset entity OR an entity-less blob. If THAT
 *                           also fails, log CRITICAL with the orphaned key.
 *  - Audit log (R3):       single structured line at every outcome (success
 *                           and each reject reason). Forensics + post-
 *                           incident reconstruction.
 *
 * See ~/.claude/plans/mcp-presigned-asset-upload.md.
 */
import { canWriteRepo } from './access-control.js';
import { verifyAssetUploadToken } from './asset-upload-token.js';
import { publicUrlForAssetId } from './assets/index.js';
import { deriveAssetDisplayName } from '../core/embeds.js';
import { getMediaType } from './assets/base.js';
import { ForbiddenError, LocalBackendNotSupportedError, UploadNotReceivedError, UploadValidationError, } from './asset-upload-errors.js';
import { log } from '../utils/log.js';
const MAX_BYTES = 20 * 1024 * 1024;
export async function finalizeAssetUpload(input) {
    // 1. Token verify (throws TokenInvalidError on any failure).
    const payload = verifyAssetUploadToken(input.token, input.authService.getJwtSecret());
    // Common audit-log scaffold so every outcome carries the same fields.
    const auditBase = {
        event: 'asset-finalize',
        userId: String(input.user.id),
        repo: payload.repo,
        ownerType: payload.ownerType,
        ownerId: payload.ownerId,
        assetId: payload.assetId,
    };
    const reject = (reason, error) => {
        log.info('[asset-finalize]', { ...auditBase, outcome: 'rejected', reason });
        throw error;
    };
    // 2. R1 identity bind: token leak isn't replayable by another user.
    //    Workspace owner is NOT exempt — the uploaderUserId rule applies to
    //    everyone, mirroring the canDelete rule on the workspace assets store.
    if (String(input.user.id) !== payload.uploaderUserId) {
        reject('identity_mismatch', new ForbiddenError('finalize_asset_upload may only be called by the user who created the upload token'));
    }
    const assetIndex = input.workspaceManager.getAssetIndex();
    // 3. Idempotency. A repeat finalize with the same token returns the
    //    existing asset row — covers agent retries on network hiccup between
    //    "201 from R2" and "response from finalize".
    const existing = assetIndex.get(payload.assetId);
    if (existing) {
        log.info('[asset-finalize]', { ...auditBase, outcome: 'idempotent' });
        return {
            assetId: existing.assetId,
            url: publicUrlForAssetId(existing.assetId, existing.filename),
            ownerType: existing.ownerType,
            ownerId: existing.ownerId,
            repo: existing.repo,
        };
    }
    // 4. Re-check folder authz. 15-min token may outlive a folder-access
    //    revoke; closes the "demoted-mid-flow" gap.
    if (!canWriteRepo(input.user, payload.repo, input.workspaceManager, input.authService)) {
        reject('forbidden_repo', new ForbiddenError(`No write access to folder "${payload.repo}"`));
    }
    // 5. Resolve the graph + asset service for the target repo.
    let graph;
    try {
        graph = input.workspaceManager.getGraph(payload.repo);
    }
    catch {
        reject('repo_not_found', new ForbiddenError(`Folder "${payload.repo}" not found`));
    }
    const assetService = graph.getAssetService();
    if (!assetService || !assetService.supportsPresignedUploads()) {
        reject('local_backend', new LocalBackendNotSupportedError('Pre-signed uploads require an R2-backed asset store'));
    }
    // 6. HEAD pending — covers "agent never PUT", wrong CT, oversize. On
    //    mismatch, delete pending immediately so a misbehaving agent's bytes
    //    don't sit on the lifecycle clock at our cost.
    const head = await assetService.headPending(payload.assetId, payload.filename);
    if (!head) {
        log.info('[asset-finalize]', { ...auditBase, outcome: 'rejected', reason: 'upload_not_received' });
        throw new UploadNotReceivedError(`No upload received at pending/${payload.assetId}/${payload.filename}. PUT may have failed or the 15-min window expired.`);
    }
    if (head.contentType !== payload.mime) {
        await safeDeletePending(assetService, payload.assetId, payload.filename);
        log.info('[asset-finalize]', {
            ...auditBase, outcome: 'rejected', reason: 'content_type_mismatch',
            expected: payload.mime, actual: head.contentType,
        });
        throw new UploadValidationError(`Stored Content-Type "${head.contentType}" does not match presign mime "${payload.mime}".`);
    }
    if (head.size > MAX_BYTES) {
        await safeDeletePending(assetService, payload.assetId, payload.filename);
        log.info('[asset-finalize]', {
            ...auditBase, outcome: 'rejected', reason: 'oversize', size: head.size, max: MAX_BYTES,
        });
        throw new UploadValidationError(`Upload size ${head.size} bytes exceeds ${MAX_BYTES} byte limit.`);
    }
    if (head.size === 0) {
        await safeDeletePending(assetService, payload.assetId, payload.filename);
        log.info('[asset-finalize]', { ...auditBase, outcome: 'rejected', reason: 'empty' });
        throw new UploadValidationError('Upload is empty (0 bytes).');
    }
    // 7. Server-side promote pending → canonical. CopyObject is atomic at
    //    R2; on failure, leave pending for the lifecycle rule.
    await assetService.copyPendingToCanonical(payload.assetId, payload.filename);
    // 8. From here on, the canonical blob EXISTS. Any failure below must
    //    delete it (R5) so we don't leave an orphan with no entity owner.
    const url = publicUrlForAssetId(payload.assetId, payload.filename);
    const name = input.name?.trim() || deriveAssetDisplayName(payload.filename) || payload.assetId;
    const mediaType = getMediaType(payload.mime, payload.filename);
    // Asset-as-embed (B2): presign produces a FREE-STANDING, folder-owned asset —
    // NO `asset` entity. The agent embeds it into an entry via
    // attach_asset_to_entity, or a user re-embeds it via the manager / "create
    // entry from asset". The folder-owned index row is the asset's record of
    // existence. Image caption/OCR indexing IS derived for it: the trigger below
    // seeds an assetId-keyed index-state row + notifies the live caption drain
    // (see ensureInitialAssetIndexState); the caption folds into any entry that
    // embeds the asset.
    try {
        assetIndex.put({
            assetId: payload.assetId,
            repo: payload.repo,
            ownerType: 'folder',
            ownerId: payload.repo,
            filename: payload.filename,
            contentType: payload.mime,
            mediaType,
            name,
            uploaderUserId: String(input.user.id),
            size: head.size,
        });
    }
    catch (err) {
        // R5: index.put failed after the canonical blob was copied — delete the
        // blob so no orphan remains; the original error propagates. (No entity to
        // roll back under the asset-as-embed model.)
        try {
            await assetService.deleteByAssetId(payload.assetId, payload.filename);
        }
        catch (cleanupErr) {
            log.warn('[asset-finalize] CRITICAL: compensating delete failed, canonical blob orphaned', {
                assetId: payload.assetId,
                canonicalKey: `${payload.assetId}/${payload.filename}`,
                cleanupErr: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
            });
        }
        log.info('[asset-finalize]', { ...auditBase, outcome: 'rejected', reason: 'app_layer_failure' });
        throw err;
    }
    // Caption/OCR trigger: the vector service ignores non-captionable media
    // types, so presigned fonts remain filename-only while raster images become
    // content-searchable. Fire-and-forget — never block finalize on best-effort
    // search enrichment.
    void input.workspaceManager.vectorService?.ensureInitialAssetIndexState(payload.assetId, {
        mediaType,
        filename: payload.filename,
        repo: payload.repo,
    });
    // 9. Pending cleanup — non-fatal. Canonical exists; lifecycle catches
    //    a missed delete.
    try {
        await assetService.deletePending(payload.assetId, payload.filename);
    }
    catch (err) {
        log.warn('[asset-finalize] pending delete failed (canonical exists; lifecycle backstop will reap)', {
            assetId: payload.assetId,
            err: err instanceof Error ? err.message : String(err),
        });
    }
    // 10. Success audit log.
    log.info('[asset-finalize]', { ...auditBase, outcome: 'success', size: head.size, mime: payload.mime });
    return {
        assetId: payload.assetId,
        url,
        ownerType: 'folder',
        ownerId: payload.repo,
        repo: payload.repo,
    };
}
/** Best-effort pending delete on validation rejects. Errors here are
 *  swallowed — the lifecycle rule is the backstop and we don't want a
 *  cleanup failure to mask the user-facing validation error. */
async function safeDeletePending(assetService, assetId, filename) {
    try {
        await assetService.deletePending(assetId, filename);
    }
    catch (err) {
        log.warn('[asset-finalize] best-effort pending delete failed', {
            assetId, filename, err: err instanceof Error ? err.message : String(err),
        });
    }
}
//# sourceMappingURL=asset-finalize-service.js.map