/**
 * Cloudflare R2 asset backend
 *
 * S3-compatible object storage with zero egress fees
 */
import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand, DeleteObjectsCommand, HeadObjectCommand, CopyObjectCommand, GetBucketLifecycleConfigurationCommand, } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getMediaType } from './base.js';
export class R2AssetBackend {
    client;
    bucket;
    publicUrl;
    constructor(config) {
        this.bucket = config.bucket;
        this.publicUrl = config.publicUrl.replace(/\/+$/, ''); // Remove trailing slashes
        // Create S3 client configured for R2
        this.client = new S3Client({
            region: 'auto',
            endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
            credentials: {
                accessKeyId: config.accessKeyId,
                secretAccessKey: config.secretAccessKey,
            },
        });
    }
    /**
     * Upload asset to R2
     */
    async upload(key, buffer, contentType) {
        try {
            await this.client.send(new PutObjectCommand({
                Bucket: this.bucket,
                Key: key,
                Body: buffer,
                ContentType: contentType,
                CacheControl: 'public, max-age=31536000, immutable', // Cache for 1 year
            }));
            const filename = key.split('/').pop() || key;
            const url = `${this.publicUrl}/${key}`;
            return {
                filename,
                size: buffer.length,
                contentType,
                url,
                path: url, // For R2, use full URL in markdown
                mediaType: getMediaType(contentType, filename),
            };
        }
        catch (error) {
            throw new Error(`Failed to upload to R2: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Server-side copy via S3 CopyObjectCommand (R2 supports it) — no download.
     * CopySource must be URL-encoded and bucket-qualified. Overwrites the
     * destination, so migration re-runs are idempotent.
     */
    async copy(srcKey, destKey) {
        try {
            await this.client.send(new CopyObjectCommand({
                Bucket: this.bucket,
                CopySource: encodeURIComponent(`${this.bucket}/${srcKey}`),
                Key: destKey,
                CacheControl: 'public, max-age=31536000, immutable',
            }));
        }
        catch (error) {
            throw new Error(`Failed to copy in R2: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Download an object's bytes from R2 into a Buffer.
     */
    async download(key) {
        try {
            const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
            if (!res.Body)
                throw new Error(`Asset not found: ${key}`);
            // res.Body is a Node Readable in the Node runtime; collect to a Buffer.
            const chunks = [];
            for await (const chunk of res.Body) {
                chunks.push(Buffer.from(chunk));
            }
            return Buffer.concat(chunks);
        }
        catch (error) {
            throw new Error(`Failed to download from R2: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * List assets with a given prefix
     */
    async list(prefix) {
        try {
            const response = await this.client.send(new ListObjectsV2Command({
                Bucket: this.bucket,
                Prefix: prefix,
            }));
            const assets = [];
            for (const obj of response.Contents || []) {
                if (!obj.Key)
                    continue;
                // Skip if it's a "directory" (ends with /)
                if (obj.Key.endsWith('/'))
                    continue;
                const filename = obj.Key.split('/').pop() || obj.Key;
                // Skip if filename contains additional slashes (nested directories)
                const relativePath = obj.Key.replace(prefix, '');
                if (relativePath.includes('/'))
                    continue;
                const url = `${this.publicUrl}/${obj.Key}`;
                // Try to get content type from metadata
                let contentType = 'application/octet-stream';
                try {
                    const headResponse = await this.client.send(new HeadObjectCommand({
                        Bucket: this.bucket,
                        Key: obj.Key,
                    }));
                    contentType = headResponse.ContentType || contentType;
                }
                catch {
                    // If HEAD fails, guess from extension
                    const ext = filename.split('.').pop()?.toLowerCase() || '';
                    contentType = this.guessContentType(ext);
                }
                assets.push({
                    filename,
                    size: obj.Size || 0,
                    contentType,
                    url,
                    path: url,
                    mediaType: getMediaType(contentType, filename),
                });
            }
            return assets;
        }
        catch (error) {
            throw new Error(`Failed to list assets from R2: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * List every asset in the bucket via paginated ListObjectsV2. Parses
     * `{type}/{id}/{filename}` out of each key and guesses content type
     * from the extension — no per-object HEAD calls.
     */
    async listAll() {
        const results = [];
        let continuationToken;
        try {
            do {
                const response = await this.client.send(new ListObjectsV2Command({
                    Bucket: this.bucket,
                    ContinuationToken: continuationToken,
                }));
                for (const obj of response.Contents || []) {
                    if (!obj.Key || obj.Key.endsWith('/'))
                        continue;
                    const parts = obj.Key.split('/');
                    // Expected layout: {type}/{id}/{filename}. Ignore anything else
                    // (e.g. theme tokens, unrelated R2 uploads sharing the bucket).
                    if (parts.length !== 3)
                        continue;
                    const [entityType, entityId, filename] = parts;
                    if (!entityType || !entityId || !filename)
                        continue;
                    const ext = filename.split('.').pop()?.toLowerCase() || '';
                    const contentType = this.guessContentType(ext);
                    const url = `${this.publicUrl}/${obj.Key}`;
                    results.push({
                        filename,
                        size: obj.Size || 0,
                        contentType,
                        url,
                        path: url,
                        mediaType: getMediaType(contentType, filename),
                        entityType,
                        entityId,
                    });
                }
                continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
            } while (continuationToken);
        }
        catch (error) {
            throw new Error(`Failed to list assets from R2: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
        return results;
    }
    /**
     * Delete a single asset
     */
    async delete(key) {
        try {
            await this.client.send(new DeleteObjectCommand({
                Bucket: this.bucket,
                Key: key,
            }));
        }
        catch (error) {
            throw new Error(`Failed to delete from R2: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Delete all assets with a given prefix
     */
    async deleteAll(prefix) {
        try {
            // First, list all objects with the prefix
            const response = await this.client.send(new ListObjectsV2Command({
                Bucket: this.bucket,
                Prefix: prefix,
            }));
            const objects = response.Contents || [];
            if (objects.length === 0) {
                return 0;
            }
            // Delete all objects
            await this.client.send(new DeleteObjectsCommand({
                Bucket: this.bucket,
                Delete: {
                    Objects: objects.map(obj => ({ Key: obj.Key })),
                    Quiet: true,
                },
            }));
            return objects.length;
        }
        catch (error) {
            throw new Error(`Failed to delete assets from R2: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Generate a short-lived pre-signed URL for a stored object.
     *
     * Stage 2 of the asset architecture: rather than handing browsers a
     * permanent public URL, the GET /api/assets/* route authorizes per-repo
     * and 302-redirects to one of these. The bucket can then be flipped to
     * private without breaking the workflow.
     *
     * Default expiry is 4 hours (14400s). Long enough that Range-request
     * continuation on multi-hour media doesn't get a SignatureExpired mid-
     * stream; short enough that out-of-band URL leakage has a bounded
     * exposure window. The leak math doesn't meaningfully shift between
     * 15 min and 4 hr — anything that exfiltrates a URL fast enough to
     * matter does so within either window.
     *
     * `method` selects the HTTP verb the URL is signed against — pre-signed
     * URLs are method-bound, so HEAD requests need a HEAD-signed URL.
     * Without this, link-unfurlers and OpenGraph crawlers that issue HEAD
     * after following a redirect get 403 SignatureDoesNotMatch from R2.
     */
    async getSignedUrl(key, expiresIn = 14400, method = 'GET') {
        const command = method === 'HEAD'
            ? new HeadObjectCommand({ Bucket: this.bucket, Key: key })
            : new GetObjectCommand({ Bucket: this.bucket, Key: key });
        return getSignedUrl(this.client, command, { expiresIn });
    }
    /**
     * Generate a short-lived pre-signed PUT URL the client uploads to directly.
     *
     * Counterpart to `getSignedUrl()` — same SDK machinery, different verb. The
     * `ContentType` is pinned into the signature: if the client sends a PUT
     * with any other `Content-Type` header, R2 rejects with
     * `SignatureDoesNotMatch`. This is the load-bearing piece of the MCP
     * presign flow's mime-type guarantee — the agent cannot upload an SVG
     * disguised as `image/png` because the bytes would be uploadable but
     * unmatched-CT requests fail at the wire.
     *
     * Default `expiresIn` is 900s (15 min) — short window matches the token's
     * `exp` so a leaked URL has the same blast radius as a leaked token.
     *
     * Returns the URL and the headers the client must send verbatim (notably
     * `Content-Type`). The SDK's `signableHeaders` defaults already cover
     * `Content-Type` and standard hosted headers; we don't add custom signed
     * headers to keep the agent's PUT trivial.
     */
    async getSignedPutUrl(key, contentType, expiresIn = 900) {
        const command = new PutObjectCommand({
            Bucket: this.bucket,
            Key: key,
            ContentType: contentType,
        });
        const uploadUrl = await getSignedUrl(this.client, command, {
            expiresIn,
            signableHeaders: new Set(['content-type']),
        });
        return { uploadUrl, headers: { 'Content-Type': contentType } };
    }
    /**
     * Fetch ContentType + ContentLength via HEAD. Returns `null` on 404 so
     * callers can distinguish "object not present" from a network/auth error.
     * Used by the MCP finalize flow to verify the pre-signed PUT landed AND
     * R2 stored the contract'd Content-Type before promoting pending/ →
     * canonical.
     */
    async head(key) {
        try {
            const response = await this.client.send(new HeadObjectCommand({
                Bucket: this.bucket,
                Key: key,
            }));
            return {
                contentType: response.ContentType ?? 'application/octet-stream',
                size: response.ContentLength ?? 0,
            };
        }
        catch (error) {
            const status = error?.$metadata?.httpStatusCode ?? error?.statusCode;
            if (status === 404 || error?.name === 'NotFound' || error?.name === 'NoSuchKey') {
                return null;
            }
            throw new Error(`Failed to HEAD key in R2: ${error instanceof Error ? error.message : 'Unknown error'}`);
        }
    }
    /**
     * Bucket lifecycle rules summarized for the MCP presign flow's boot-time
     * backstop (see docs/r2-lifecycle.md). The full S3 schema is much richer
     * than what we need — only the prefix + ExpirationDays per rule matters
     * for the "is `pending/` reaped within 24h?" check.
     *
     * `null` when the bucket has no lifecycle configuration at all (R2
     * returns `NoSuchLifecycleConfiguration`). Throws on other SDK errors so
     * the caller can decide whether to degrade or propagate.
     */
    async getBucketLifecycle() {
        try {
            const response = await this.client.send(new GetBucketLifecycleConfigurationCommand({ Bucket: this.bucket }));
            return (response.Rules ?? []).map(rule => ({
                // S3 lifecycle rules support either `Prefix` (legacy) or
                // `Filter.Prefix` / `Filter.And.Prefix` (current). R2 accepts both
                // — flatten so the caller has one shape to inspect.
                prefix: rule.Filter && 'Prefix' in rule.Filter ? rule.Filter.Prefix
                    : rule.Filter?.And?.Prefix
                        ?? rule.Prefix,
                expirationDays: rule.Expiration?.Days,
                // S3 lifecycle Status is 'Enabled' | 'Disabled'. A disabled rule
                // doesn't fire even if its prefix and TTL look right — surfacing
                // this lets the lifecycle backstop reject configured-but-disabled
                // rules (codex P2, 2026-05-28).
                enabled: rule.Status === 'Enabled',
            }));
        }
        catch (error) {
            if (error?.name === 'NoSuchLifecycleConfiguration')
                return null;
            throw error;
        }
    }
    /**
     * Check if an asset exists
     */
    async exists(key) {
        try {
            await this.client.send(new HeadObjectCommand({
                Bucket: this.bucket,
                Key: key,
            }));
            return true;
        }
        catch {
            return false;
        }
    }
    /**
     * Guess content type from file extension
     */
    guessContentType(ext) {
        const contentTypes = {
            png: 'image/png',
            jpg: 'image/jpeg',
            jpeg: 'image/jpeg',
            gif: 'image/gif',
            webp: 'image/webp',
            svg: 'image/svg+xml',
            mp4: 'video/mp4',
            mov: 'video/quicktime',
            webm: 'video/webm',
            mp3: 'audio/mpeg',
            wav: 'audio/wav',
            pdf: 'application/pdf',
            zip: 'application/zip',
        };
        return contentTypes[ext] || 'application/octet-stream';
    }
}
//# sourceMappingURL=r2.js.map