/**
 * Cloudflare R2 asset backend
 *
 * S3-compatible object storage with zero egress fees
 */
import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand, DeleteObjectsCommand, HeadObjectCommand, CopyObjectCommand, } 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),
            };
        }
        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'}`);
        }
    }
    /**
     * 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),
                });
            }
            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),
                        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 });
    }
    /**
     * 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