import * as mf from "miniflare";
import { isDeepStrictEqual } from "node:util";
import { Resource, ResourceKind } from "../resource.js";
import { Scope } from "../scope.js";
import { streamToBuffer } from "../serde.js";
import { isRetryableError } from "../state/r2-rest-state-store.js";
import { withExponentialBackoff } from "../util/retry.js";
import { CloudflareApiError, handleApiError } from "./api-error.js";
import { extractCloudflareResult, } from "./api-response.js";
import { createCloudflareApi, } from "./api.js";
import { R2BucketCustomDomain, } from "./bucket-custom-domain.js";
import { deleteMiniflareBinding } from "./miniflare/delete.js";
import { getDefaultPersistPath } from "./miniflare/paths.js";
export function isBucket(resource) {
    return resource?.[ResourceKind] === "cloudflare::R2Bucket";
}
/**
 * Creates and manages Cloudflare R2 Buckets for object storage.
 *
 * R2 Buckets provide S3-compatible object storage with automatic data replication
 * across multiple regions for high availability and durability.
 *
 * @example
 * // Create a basic R2 bucket with default settings
 * const basicBucket = await R2Bucket("my-app-data", {
 *   name: "my-app-data"
 * });
 *
 * @example
 * // Create a bucket with location hint for optimal performance
 * const euBucket = await R2Bucket("eu-user-data", {
 *   name: "eu-user-data",
 *   locationHint: "eu",
 *   jurisdiction: "eu"
 * });
 *
 * @example
 * // Create a development bucket with public access enabled
 * const publicBucket = await R2Bucket("public-assets", {
 *   name: "public-assets",
 *   allowPublicAccess: true
 * });
 *
 * @example
 * // Create a FedRAMP compliant bucket for government workloads
 * const fedRampBucket = await R2Bucket("gov-data", {
 *   name: "gov-data",
 *   jurisdiction: "fedramp"
 * });
 *
 * @example
 * // Create a bucket that will be automatically emptied when deleted
 * // This will delete all objects in the bucket before deleting the bucket itself
 * const temporaryBucket = await R2Bucket("temp-storage", {
 *   name: "temp-storage",
 *   empty: true  // All objects will be deleted when this resource is destroyed
 * });
 *
 * @see https://developers.cloudflare.com/r2/buckets/
 */
export async function R2Bucket(id, props = {}) {
    const scope = Scope.current;
    const isLocal = scope.local && props.dev?.remote !== true;
    const api = await createCloudflareApi(props);
    const bucket = await _R2Bucket(id, {
        ...props,
        dev: {
            ...(props.dev ?? {}),
            force: Scope.current.local,
        },
    });
    let _miniflare;
    const miniflare = () => {
        if (_miniflare) {
            return _miniflare;
        }
        _miniflare = new mf.Miniflare({
            script: "",
            modules: true,
            defaultPersistRoot: getDefaultPersistPath(scope.rootDir),
            r2Buckets: [bucket.dev.id],
            log: process.env.DEBUG ? new mf.Log(mf.LogLevel.DEBUG) : undefined,
        });
        scope.onCleanup(async () => _miniflare?.dispose());
        return _miniflare;
    };
    const localBucket = () => miniflare().getR2Bucket(bucket.dev.id);
    return {
        ...bucket,
        head: async (key) => {
            if (isLocal) {
                const result = await (await localBucket()).head(key);
                if (result) {
                    return {
                        key: result.key,
                        etag: result.etag,
                        uploaded: result.uploaded,
                        size: result.size,
                        httpMetadata: result.httpMetadata,
                    };
                }
                return null;
            }
            return headObject(api, {
                bucketName: bucket.name,
                key,
            });
        },
        get: async (key) => {
            if (isLocal) {
                const result = await (await localBucket()).get(key);
                if (result) {
                    // cast because workers vs node built-ins
                    return result;
                }
                return null;
            }
            const response = await getObject(api, {
                bucketName: bucket.name,
                key,
            });
            if (response.ok) {
                return parseR2Object(key, response);
            }
            else if (response.status === 404) {
                return null;
            }
            else {
                throw await handleApiError(response, "get", "object", key);
            }
        },
        list: async (options) => {
            if (isLocal) {
                return (await localBucket()).list(options);
            }
            return listObjects(api, bucket.name, {
                ...options,
                jurisdiction: bucket.jurisdiction,
            });
        },
        put: async (key, value, options) => {
            if (isLocal) {
                return await (await localBucket()).put(key, typeof value === "string"
                    ? value
                    : Buffer.isBuffer(value) ||
                        value instanceof Uint8Array ||
                        value instanceof ArrayBuffer
                        ? new Uint8Array(value)
                        : value instanceof Blob
                            ? new Uint8Array(await value.arrayBuffer())
                            : value instanceof ReadableStream
                                ? new Uint8Array(await streamToBuffer(value))
                                : value, 
                // @ts-expect-error - headers type mismatch
                options);
            }
            const response = await putObject(api, {
                bucketName: bucket.name,
                key: key,
                object: value,
                options: options,
            });
            const body = (await response.json());
            return {
                key: body.result.key,
                etag: body.result.etag,
                uploaded: new Date(body.result.uploaded),
                version: body.result.version,
                size: Number(body.result.size),
            };
        },
        delete: async (key) => {
            if (isLocal) {
                await (await localBucket()).delete(key);
            }
            return deleteObject(api, {
                bucketName: bucket.name,
                key: key,
            });
        },
    };
}
const parseR2Object = (key, response) => ({
    etag: response.headers.get("ETag"),
    uploaded: parseDate(response.headers),
    key,
    size: Number(response.headers.get("Content-Length")),
    httpMetadata: mapHeadersToHttpMetadata(response.headers),
    arrayBuffer: () => response.arrayBuffer(),
    bytes: () => response.bytes(),
    text: () => response.text(),
    json: () => response.json(),
    blob: () => response.blob(),
});
const parseDate = (headers) => new Date(headers.get("Last-Modified") ?? headers.get("Date"));
const _R2Bucket = Resource("cloudflare::R2Bucket", async function (id, props = {}) {
    const bucketName = props.name ?? (this.output?.name || this.scope.createPhysicalName(id));
    if (this.phase === "update" && this.output?.name !== bucketName) {
        this.replace(
        // force replacement if custom domain is enabled; otherwise, replace will fail because the bucket has child resources
        !!this.output.domains?.length);
    }
    const isDevDomainEnabled = props.devDomain === true || props.allowPublicAccess === true;
    const isLocal = this.scope.local && !props.dev?.remote;
    const dev = {
        id: this.output?.dev?.id ?? bucketName,
        remote: props.dev?.remote ?? false,
        isDeployed: this.output?.dev?.isDeployed || !isLocal,
    };
    const adopt = props.adopt ?? this.scope.adopt;
    async function createDomains(domain) {
        return Promise.all((Array.isArray(domain) ? domain : [domain]).map(async (domain) => {
            const options = typeof domain === "string"
                ? { domain, adopt, delete: props.delete }
                : {
                    ...domain,
                    adopt: domain.adopt ?? adopt,
                    delete: domain.delete ?? props.delete,
                };
            const output = await R2BucketCustomDomain(options.domain, {
                ...options,
                accountId: props.accountId,
                apiKey: props.apiKey,
                apiToken: props.apiToken,
                email: props.email,
                baseUrl: props.baseUrl,
                profile: props.profile,
                bucketName: bucketName,
                jurisdiction: props.jurisdiction,
                dev,
            });
            return output.domain;
        }));
    }
    if (isLocal) {
        return {
            name: bucketName,
            location: this.output?.location ?? "",
            creationDate: this.output?.creationDate ?? new Date(),
            jurisdiction: this.output?.jurisdiction ?? "default",
            devDomain: this.output?.devDomain ?? this.output?.domain,
            domains: props.domains ? await createDomains(props.domains) : undefined,
            type: "r2_bucket",
            accountId: this.output?.accountId ?? "",
            cors: props.cors,
            dev,
        };
    }
    const api = await createCloudflareApi(props);
    if (this.phase === "delete") {
        if (props.delete !== false) {
            // Use bucket's actual jurisdiction from output, falling back to props
            // This ensures EU/FedRAMP buckets are correctly addressed during cleanup
            const deleteProps = {
                ...props,
                jurisdiction: this.output?.jurisdiction ?? props.jurisdiction,
            };
            if (this.output?.catalog) {
                await disableDataCatalog(api, bucketName);
            }
            if (this.output.dev?.id) {
                await deleteMiniflareBinding(this.scope, "r2", this.output.dev.id);
            }
            if (props.empty) {
                await emptyBucket(api, bucketName, deleteProps);
            }
            await deleteBucket(api, bucketName, deleteProps);
        }
        return this.destroy();
    }
    if (this.phase === "create" || !this.output?.dev?.isDeployed) {
        const bucket = await createBucket(api, bucketName, props).catch(async (err) => {
            if (err instanceof CloudflareApiError &&
                err.status === 409 &&
                adopt) {
                return await getBucket(api, bucketName, props);
            }
            throw err;
        });
        const devDomain = await putManagedDomain(api, bucketName, isDevDomainEnabled, props.jurisdiction);
        if (props.cors?.length) {
            await putBucketCORS(api, bucketName, props);
        }
        if (props.lifecycle?.length) {
            await putBucketLifecycleRules(api, bucketName, props);
        }
        if (props.lock?.length) {
            await putBucketLockRules(api, bucketName, props);
        }
        let dataCatalog;
        if (props.dataCatalog) {
            dataCatalog = await enableDataCatalog(api, bucketName);
        }
        return {
            name: bucketName,
            location: bucket.location,
            creationDate: new Date(bucket.creation_date),
            jurisdiction: bucket.jurisdiction,
            devDomain,
            domains: props.domains ? await createDomains(props.domains) : undefined,
            type: "r2_bucket",
            accountId: api.accountId,
            lifecycle: props.lifecycle,
            lock: props.lock,
            cors: props.cors,
            dev,
            catalog: dataCatalog,
        };
    }
    else {
        if (bucketName !== this.output.name) {
            throw new Error(`Cannot update R2Bucket name after creation. Bucket name is immutable. Before: ${this.output.name}, After: ${bucketName}`);
        }
        if (this.output?.catalog && !props.dataCatalog) {
            await disableDataCatalog(api, bucketName);
            this.output.catalog = undefined;
        }
        if (!this.output.catalog && props.dataCatalog) {
            this.output.catalog = await enableDataCatalog(api, bucketName);
        }
        let devDomain = this.output.devDomain ?? this.output.domain;
        if (!!devDomain !== isDevDomainEnabled) {
            devDomain = await putManagedDomain(api, bucketName, isDevDomainEnabled, props.jurisdiction);
        }
        if (!isDeepStrictEqual(this.output.cors ?? [], props.cors ?? [])) {
            await putBucketCORS(api, bucketName, props);
        }
        if (!isDeepStrictEqual(this.output.lifecycle ?? [], props.lifecycle ?? [])) {
            await putBucketLifecycleRules(api, bucketName, props);
        }
        if (!isDeepStrictEqual(this.output.lock ?? [], props.lock ?? [])) {
            await putBucketLockRules(api, bucketName, props);
        }
        return {
            ...this.output,
            devDomain,
            domains: props.domains ? await createDomains(props.domains) : undefined,
            dev,
            cors: props.cors,
            lifecycle: props.lifecycle,
            lock: props.lock,
        };
    }
});
/**
 * Adds jurisdiction header to the headers object if specified in props
 *
 * @param headers Headers object to modify
 * @param props Props or jurisdiction string
 * @returns Modified headers object
 */
export function withJurisdiction(props, headers = {}) {
    if (props.jurisdiction && props.jurisdiction !== "default") {
        headers["cf-r2-jurisdiction"] = props.jurisdiction;
    }
    return headers;
}
/**
 * Get a bucket
 */
export async function getBucket(api, bucketName, props = {}) {
    return await extractCloudflareResult(`get R2 bucket "${bucketName}"`, api.get(`/accounts/${api.accountId}/r2/buckets/${bucketName}`, {
        headers: withJurisdiction(props),
    }));
}
/**
 * Create a new bucket
 */
export async function createBucket(api, bucketName, props = {}) {
    return await extractCloudflareResult(`create R2 bucket "${bucketName}"`, api.post(`/accounts/${api.accountId}/r2/buckets`, {
        name: bucketName,
        locationHint: props.locationHint,
        storageClass: props.storageClass,
    }, {
        headers: withJurisdiction(props),
    }));
}
/**
 * Delete a bucket
 */
export async function deleteBucket(api, bucketName, props) {
    try {
        await extractCloudflareResult(`delete R2 bucket "${bucketName}"`, api.delete(`/accounts/${api.accountId}/r2/buckets/${bucketName}`, {
            headers: withJurisdiction(props),
        }));
    }
    catch (error) {
        if (error instanceof CloudflareApiError && error.status === 404) {
            return;
        }
        throw error;
    }
}
/**
 * Update the managed domain setting for a bucket
 */
export async function putManagedDomain(api, bucketName, enabled, jurisdiction) {
    return await withExponentialBackoff(async () => {
        const result = await extractCloudflareResult(`put R2 bucket managed domain for "${bucketName}"`, api.put(`/accounts/${api.accountId}/r2/buckets/${bucketName}/domains/managed`, { enabled }, { headers: withJurisdiction({ jurisdiction }) }));
        return result.enabled ? result.domain : undefined;
    }, (err) => err.status === 404, 10, 1000);
}
/**
 * Delete all objects in a bucket
 */
async function emptyBucket(api, bucketName, props) {
    let cursor;
    while (true) {
        const result = await listObjects(api, bucketName, {
            jurisdiction: props.jurisdiction,
            cursor,
        });
        if (result.objects.length) {
            // Another undocumented API! But it lets us delete multiple objects at once instead of one by one.
            await extractCloudflareResult(`delete ${result.objects.length} objects from bucket "${bucketName}"`, api.delete(`/accounts/${api.accountId}/r2/buckets/${bucketName}/objects`, {
                headers: withJurisdiction(props),
                method: "DELETE",
                body: JSON.stringify(result.objects.map((object) => object.key)),
            }));
            if (result.cursor) {
                cursor = result.cursor;
                continue;
            }
        }
        break;
    }
}
/**
 * Lists objects in a bucket.
 */
export async function listObjects(api, bucketName, props) {
    const params = new URLSearchParams({
        per_page: "1000",
    });
    if (props.cursor) {
        params.set("cursor", props.cursor);
    }
    if (props.delimiter) {
        params.set("delimiter", props.delimiter);
    }
    if (props.prefix) {
        params.set("prefix", props.prefix);
    }
    if (props.startAfter) {
        params.set("start_after", props.startAfter);
    }
    if (props.limit) {
        params.set("limit", props.limit.toString());
    }
    const response = await api.get(`/accounts/${api.accountId}/r2/buckets/${bucketName}/objects?${params.toString()}`, { headers: withJurisdiction(props) });
    const json = await response.json();
    if (!json.success) {
        // 10006 indicates that the bucket does not exist, so there are no objects to list
        if (json.errors.some((e) => e.code === 10006)) {
            return {
                objects: [],
                cursor: undefined,
                truncated: false,
            };
        }
        throw new CloudflareApiError(`Failed to list objects in bucket "${bucketName}": ${json.errors.map((e) => `- [${e.code}] ${e.message}${e.documentation_url ? ` (${e.documentation_url})` : ""}`).join("\n")}`, response, json.errors);
    }
    return {
        // keys: json.result.map((object) => object.key),
        objects: json.result.map((object) => ({
            key: object.key,
            etag: object.etag,
            uploaded: new Date(object.last_modified),
            size: object.size,
        })),
        delimitedPrefixes: [],
        cursor: json.result_info?.cursor,
        truncated: json.result_info?.is_truncated ?? false,
    };
}
/**
 * List all R2 buckets in an account
 *
 * @param api CloudflareApi instance
 * @param options Optional listing options
 * @returns Array of bucket information
 */
export async function listBuckets(api, options = {}) {
    // Build query parameters
    const params = new URLSearchParams();
    if (options.nameContains) {
        params.append("name_contains", options.nameContains);
    }
    if (options.perPage) {
        params.append("per_page", options.perPage.toString());
    }
    if (options.cursor) {
        params.append("cursor", options.cursor);
    }
    if (options.direction) {
        params.append("direction", options.direction);
    }
    // Build URL with query parameters
    const path = `/accounts/${api.accountId}/r2/buckets${params.toString() ? `?${params.toString()}` : ""}`;
    // Make the API request
    const result = await extractCloudflareResult("list R2 buckets", api.get(path, {
        headers: withJurisdiction(options),
    }));
    return result.buckets;
}
export async function putBucketCORS(api, bucketName, props) {
    let request;
    if (props.cors?.length) {
        request = {
            method: "PUT",
            body: JSON.stringify({ rules: props.cors }),
            headers: withJurisdiction(props, {
                "Content-Type": "application/json",
            }),
        };
    }
    else {
        request = {
            method: "DELETE",
            headers: withJurisdiction(props),
        };
    }
    await extractCloudflareResult(`${request.method} R2 bucket CORS rules for "${bucketName}"`, api.fetch(`/accounts/${api.accountId}/r2/buckets/${bucketName}/cors`, request));
}
export async function putBucketLifecycleRules(api, bucketName, props) {
    const rulesBody = Array.isArray(props.lifecycle)
        ? props.lifecycle.length === 0
            ? { rules: [] }
            : {
                rules: props.lifecycle.map((rule) => ({
                    ...rule,
                    // Required by the API; empty prefix means all objects/uploads
                    conditions: rule.conditions ?? { prefix: "" },
                    // Required by the API
                    enabled: rule.enabled ?? true,
                })),
            }
        : {};
    await extractCloudflareResult(`put R2 bucket lifecycle rules for "${bucketName}"`, api.put(`/accounts/${api.accountId}/r2/buckets/${bucketName}/lifecycle`, rulesBody, { headers: withJurisdiction(props) }));
}
/**
 * Get lifecycle rules for a bucket
 */
export async function getBucketLifecycleRules(api, bucketName, props) {
    const res = await api.get(`/accounts/${api.accountId}/r2/buckets/${bucketName}/lifecycle`, { headers: withJurisdiction(props) });
    const json = await res.json();
    if (!json?.success) {
        throw new CloudflareApiError(`Failed to get R2 bucket lifecycle rules for "${bucketName}": ${res.status} ${res.statusText}`, res, json?.errors);
    }
    const rules = Array.isArray(json.result)
        ? json.result
        : (json.result?.rules ?? []);
    return rules;
}
export async function putBucketLockRules(api, bucketName, props) {
    const rulesBody = Array.isArray(props.lock)
        ? props.lock.length === 0
            ? { rules: [] }
            : {
                rules: props.lock.map((rule) => ({
                    ...rule,
                    // Required by the API
                    enabled: rule.enabled ?? true,
                })),
            }
        : {};
    await extractCloudflareResult(`put R2 bucket lock rules for "${bucketName}"`, api.put(`/accounts/${api.accountId}/r2/buckets/${bucketName}/lock`, rulesBody, { headers: withJurisdiction(props) }));
}
/**
 * Get lock rules for a bucket
 */
export async function getBucketLockRules(api, bucketName, props) {
    const res = await api.get(`/accounts/${api.accountId}/r2/buckets/${bucketName}/lock`, { headers: withJurisdiction(props) });
    const json = await res.json();
    if (!json?.success) {
        throw new CloudflareApiError(`Failed to get R2 bucket lock rules for "${bucketName}": ${res.status} ${res.statusText}`, res, json?.errors);
    }
    const rules = Array.isArray(json.result)
        ? json.result
        : (json.result?.rules ?? []);
    return rules;
}
export async function headObject(api, { bucketName, key }) {
    const response = await withRetries(async () => await api.get(`/accounts/${api.accountId}/r2/buckets/${bucketName}/objects/${key}`));
    // for some reason HEAD returns 404 for keys that exist, this is the best we can do without using S3 API
    response.body?.cancel();
    if (response.status === 404) {
        return null;
    }
    else if (!response.ok) {
        throw await handleApiError(response, "head", "object", key);
    }
    return {
        key,
        etag: response.headers.get("ETag")?.replace(/"/g, ""),
        uploaded: parseDate(response.headers),
        size: Number(response.headers.get("Content-Length")),
        httpMetadata: mapHeadersToHttpMetadata(response.headers),
    };
}
const withRetries = (f) => {
    return withExponentialBackoff(f, isRetryableError, 5, 1000);
};
export async function getObject(api, { bucketName, key }) {
    return await withRetries(async () => {
        const response = await api.get(`/accounts/${api.accountId}/r2/buckets/${bucketName}/objects/${key}`, {
            headers: {
                "Content-Type": "application/octet-stream",
                Accept: "application/octet-stream",
            },
        });
        if (!response.ok && response.status !== 404) {
            throw await handleApiError(response, "get", "object", key);
        }
        return response;
    });
}
function mapHttpMetadataToHeaders(httpMetadata) {
    const headers = {};
    if (httpMetadata instanceof Headers) {
        httpMetadata.forEach((value, key) => {
            headers[key] = value;
        });
    }
    else {
        const { contentType, contentLanguage, contentDisposition, contentEncoding, cacheControl, cacheExpiry, } = httpMetadata;
        if (contentType)
            headers["Content-Type"] = contentType;
        if (contentLanguage)
            headers["Content-Language"] = contentLanguage;
        if (contentDisposition)
            headers["Content-Disposition"] = contentDisposition;
        if (contentEncoding)
            headers["Content-Encoding"] = contentEncoding;
        if (cacheControl)
            headers["Cache-Control"] = cacheControl;
        if (cacheExpiry)
            headers.Expires = cacheExpiry.toUTCString();
    }
    return headers;
}
function mapHeadersToHttpMetadata(headers) {
    return {
        contentType: headers.get("Content-Type") ?? undefined,
        contentLanguage: headers.get("Content-Language") ?? undefined,
        contentDisposition: headers.get("Content-Disposition") ?? undefined,
        contentEncoding: headers.get("Content-Encoding") ?? undefined,
        cacheControl: headers.get("Cache-Control") ?? undefined,
        cacheExpiry: headers.get("Expires")
            ? new Date(headers.get("Expires"))
            : undefined,
    };
}
export async function putObject(api, { bucketName, key, object, options, }) {
    // Using withExponentialBackoff for reliability
    return await withRetries(async () => {
        const headers = {
            "Content-Type": "application/octet-stream",
            ...(options?.httpMetadata
                ? mapHttpMetadataToHeaders(options?.httpMetadata)
                : {}),
        };
        const response = await api.put(`/accounts/${api.accountId}/r2/buckets/${bucketName}/objects/${key}`, object, {
            headers,
        });
        if (!response.ok) {
            await handleApiError(response, "put", "object", key);
        }
        return response;
    });
}
export async function deleteObject(api, { bucketName, key }) {
    return await withRetries(async () => {
        const response = await api.delete(`/accounts/${api.accountId}/r2/buckets/${bucketName}/objects/${key}`);
        if (!response.ok && response.status !== 404) {
            await handleApiError(response, "delete", "object", key);
        }
        return response;
    });
}
export async function enableDataCatalog(api, bucketName) {
    const response = await extractCloudflareResult(`enable data catalog for bucket "${bucketName}"`, api.post(`/accounts/${api.accountId}/r2-catalog/${bucketName}/enable`, {}));
    return {
        id: response.id,
        name: response.name,
        host: `https://catalog.cloudflarestorage.com/${response.name.replace("_", "/")}`,
    };
}
export async function disableDataCatalog(api, bucketName) {
    await api.post(`/accounts/${api.accountId}/r2-catalog/${bucketName}/disable`, {});
}
//# sourceMappingURL=bucket.js.map