import { StructuredError, safeStringify } from '@agentuity/adapter';
import { buildUrl, toServiceException } from '@agentuity/adapter';
import { ListParamsSchema } from '@agentuity/client';
import { z } from 'zod';
/**
 * Minimum TTL value in seconds (1 minute)
 */
export const STREAM_MIN_TTL_SECONDS = 60;
/**
 * Maximum TTL value in seconds (90 days)
 */
export const STREAM_MAX_TTL_SECONDS = 7776000;
/**
 * Default TTL value in seconds (30 days) - used when no TTL is specified
 */
export const STREAM_DEFAULT_TTL_SECONDS = 2592000;
// Web Streams API (ReadableStream, WritableStream, CompressionStream) are globally
// available in all modern runtimes: Node.js 18+, Bun, Deno, and browsers.
const NativeWritableStream = globalThis.WritableStream;
const NativeReadableStream = globalThis.ReadableStream;
const NativeCompressionStream = globalThis.CompressionStream;
/**
 * Properties for creating a stream
 */
export const CreateStreamPropsSchema = z.object({
    /**
     * optional metadata for the stream
     */
    metadata: z
        .record(z.string(), z.string())
        .optional()
        .describe('optional metadata for the stream'),
    /**
     * optional contentType for the stream data. If not set, defaults to application/octet-stream
     */
    contentType: z
        .string()
        .optional()
        .describe('optional contentType for the stream data. If not set, defaults to application/octet-stream'),
    /**
     * optional flag to enable gzip compression of stream data during upload. if true, will also
     * add Content-Encoding: gzip header to responses. The client MUST be able to accept gzip
     * compression for this to work or must be able to uncompress the raw data it receives.
     */
    compress: z
        .literal(true)
        .optional()
        .describe('optional flag to enable gzip compression of stream data during upload. if true, will also add Content-Encoding: gzip header to responses.'),
    /**
     * optional time-to-live in seconds for the stream. Controls when the stream expires and is automatically deleted.
     * - `undefined` (not provided): Stream expires after 30 days (default)
     * - `null` or `0`: Stream never expires
     * - positive number (≥60): Stream expires after the specified number of seconds (max 90 days)
     *
     * @remarks
     * TTL values below 60 seconds are clamped to 60 seconds by the server.
     * TTL values above 7,776,000 seconds (90 days) are clamped to 90 days.
     *
     * @default 2592000 (30 days)
     */
    ttl: z
        .number()
        .nullable()
        .optional()
        .describe('optional time-to-live in seconds for the stream. Controls when the stream expires and is automatically deleted.'),
});
export const StreamSortFieldSchema = z.enum([
    'name',
    'created',
    'updated',
    'size',
    'count',
    'lastUsed',
]);
/**
 * Parameters for listing streams.
 *
 * Note: If both `namespace` and `name` are provided, `namespace` takes precedence
 * and `name` is ignored in the API request body.
 */
export const ListStreamsParamsSchema = ListParamsSchema(StreamSortFieldSchema).extend({
    /**
     * optional namespace filter to search for streams
     */
    namespace: z.string().optional().describe('optional namespace filter to search for streams'),
    /**
     * optional name filter to search for streams
     */
    name: z.string().optional().describe('optional name filter to search for streams'),
    /**
     * optional metadata filters to match streams
     */
    metadata: z
        .record(z.string(), z.string())
        .optional()
        .describe('optional metadata filters to match streams'),
});
/**
 * Stream information returned by list operation
 */
export const StreamInfoSchema = z.object({
    /**
     * unique stream identifier
     */
    id: z.string().describe('unique stream identifier'),
    /**
     * the namespace of the stream
     */
    namespace: z.string().describe('the namespace of the stream'),
    /**
     * the stream metadata
     */
    metadata: z.record(z.string(), z.string()).describe('the stream metadata'),
    /**
     * the public URL to access the stream
     */
    url: z.string().describe('the public URL to access the stream'),
    /**
     * the size of the stream in bytes
     */
    sizeBytes: z.number().describe('the size of the stream in bytes'),
    /**
     * ISO 8601 timestamp when stream expires, or null if stream never expires
     */
    expiresAt: z
        .string()
        .nullable()
        .describe('ISO 8601 timestamp when stream expires, or null if stream never expires'),
});
/**
 * Response from listing streams
 */
export const ListStreamsResponseSchema = z.object({
    /**
     * whether the request was successful
     */
    success: z.boolean().describe('whether the request was successful'),
    /**
     * optional error message if not successful
     */
    message: z.string().optional().describe('optional error message if not successful'),
    /**
     * array of streams matching the filter criteria
     */
    streams: z.array(StreamInfoSchema).describe('array of streams matching the filter criteria'),
    /**
     * total count of streams matching the filter (useful for pagination)
     */
    total: z.number().describe('total count of streams matching the filter (useful for pagination)'),
});
const encoder = new TextEncoder();
const ReadStreamFailedError = StructuredError('ReadStreamFailedError')();
/**
 * A writable stream implementation using composition (browser-compatible)
 * This approach works across all environments since native WritableStream can't be properly extended
 */
class StreamImpl {
    id;
    url;
    #writable;
    #compressed;
    #adapter;
    #sink;
    #closed = false;
    constructor(id, url, compressed, sink, writable, adapter) {
        this.id = id;
        this.url = url;
        this.#compressed = compressed;
        this.#adapter = adapter;
        this.#sink = sink;
        this.#writable = writable;
    }
    get bytesWritten() {
        return this.#sink.total;
    }
    get compressed() {
        return this.#compressed;
    }
    // WritableStream interface properties
    get locked() {
        return this.#writable.locked;
    }
    /**
     * Write data to the stream
     */
    async write(chunk) {
        let binaryChunk;
        if (chunk instanceof Uint8Array) {
            binaryChunk = chunk;
        }
        else if (typeof chunk === 'string') {
            binaryChunk = encoder.encode(chunk);
        }
        else if (chunk instanceof ArrayBuffer) {
            binaryChunk = new Uint8Array(chunk);
        }
        else if (typeof chunk === 'object' && chunk !== null) {
            binaryChunk = encoder.encode(safeStringify(chunk));
        }
        else {
            binaryChunk = encoder.encode(String(chunk));
        }
        // Delegate to the underlying sink's write method
        await this.#sink.write(binaryChunk);
    }
    /**
     * Close the stream gracefully, handling already closed streams without error
     */
    async close() {
        if (this.#closed) {
            return;
        }
        this.#closed = true;
        try {
            await this.#sink.close();
        }
        catch (error) {
            // If we get a TypeError about the stream being closed, locked, or errored,
            // that means pipeTo() or another operation already closed it or it's in use
            if (error instanceof TypeError &&
                (error.message.includes('closed') ||
                    error.message.includes('errored') ||
                    error.message.includes('Cannot close'))) {
                // Silently return - this is the desired behavior
                return;
            }
            // If the stream is locked, try to close the underlying writer
            if (error instanceof TypeError && error.message.includes('locked')) {
                // Best-effort closure for locked streams
                return;
            }
            // Re-throw any other errors
            throw error;
        }
    }
    /**
     * Abort the stream with an optional reason
     */
    abort(reason) {
        return this.#writable.abort(reason);
    }
    /**
     * Get a writer for the underlying stream
     */
    getWriter() {
        return this.#writable.getWriter();
    }
    /**
     * Get a ReadableStream that streams from the internal URL
     *
     * Note: This method will block waiting for data until writes start to the Stream.
     * The returned ReadableStream will remain open until the Stream is closed or an error occurs.
     *
     * @returns a ReadableStream that can be passed to response.stream()
     */
    getReader() {
        const url = this.url;
        const adapter = this.#adapter;
        let ac = null;
        // Use native ReadableStream to avoid polyfill interference
        return new NativeReadableStream({
            async start(controller) {
                try {
                    ac = new AbortController();
                    const res = await adapter.invoke(url, {
                        method: 'GET',
                        signal: ac.signal,
                        binary: true,
                    });
                    const response = res.response;
                    if (!res.ok) {
                        controller.error(new ReadStreamFailedError({
                            status: response.status,
                            message: `Failed to read stream: ${response.status} ${response.statusText}`,
                        }));
                        return;
                    }
                    if (!response.body) {
                        controller.error(new ReadStreamFailedError({
                            status: response.status,
                            message: 'Response body was null',
                        }));
                        return;
                    }
                    const reader = response.body.getReader();
                    try {
                        // Iterative read to avoid recursive promise chains
                        while (true) {
                            const { done, value } = await reader.read();
                            if (done)
                                break;
                            if (value)
                                controller.enqueue(value);
                        }
                        controller.close();
                    }
                    catch (error) {
                        controller.error(error);
                    }
                }
                catch (error) {
                    controller.error(error);
                }
            },
            cancel(reason) {
                if (ac) {
                    ac.abort(reason);
                    ac = null;
                }
            },
        });
    }
}
const StreamAPIError = StructuredError('StreamAPIError')();
/**
 * Check if compression is available in the current environment.
 * CompressionStream is available in:
 * - Node.js 18+ (via stream/web)
 * - Chrome 80+
 * - Safari 16.4+
 * - Firefox 113+
 */
function isCompressionAvailable() {
    return typeof NativeCompressionStream !== 'undefined' && NativeCompressionStream !== null;
}
/**
 * State object that handles streaming to the backend using the append API.
 * Each write() call sends data immediately via a separate HTTP POST request,
 * enabling real-time streaming without buffering issues.
 */
class UnderlyingSinkState {
    adapter;
    total = 0;
    closed = false;
    url;
    props;
    compressionEnabled = false;
    writable = null;
    constructor(url, adapter, props) {
        this.url = url;
        this.adapter = adapter;
        this.props = props;
    }
    async start() {
        // Check if compression is enabled and available
        this.compressionEnabled = !!(this.props?.compress && isCompressionAvailable());
        // Create a WritableStream that wraps our append-based write
        this.writable = new NativeWritableStream({
            write: async (chunk) => {
                await this.write(chunk);
            },
            close: async () => {
                await this.close();
            },
            abort: async (reason) => {
                await this.abort(reason);
            },
        });
        return this.writable;
    }
    async write(chunk) {
        if (this.closed) {
            return;
        }
        // Note: For append-based streaming, we don't compress individual chunks
        // because each would become a separate gzip stream that can't be concatenated.
        // Instead, compression is handled server-side during the complete phase.
        this.total += chunk.length;
        // Send the chunk immediately via POST to /append endpoint
        const appendUrl = `${this.url}/append`;
        const signal = AbortSignal.timeout(30_000);
        const res = await this.adapter.invoke(appendUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/octet-stream',
            },
            body: chunk,
            signal,
        });
        if (!res.ok) {
            throw new StreamAPIError({
                status: res.response.status,
                message: `Append request failed: ${res.response.status} ${res.response.statusText}`,
            });
        }
    }
    async close() {
        if (this.closed) {
            return;
        }
        this.closed = true;
        // Call the complete endpoint to finalize the stream
        // Pass compress flag to request server-side compression
        const completeUrl = `${this.url}/complete`;
        const signal = AbortSignal.timeout(60_000); // Longer timeout for compression
        const headers = {};
        if (this.compressionEnabled) {
            headers['X-Compress'] = 'gzip';
        }
        const res = await this.adapter.invoke(completeUrl, {
            method: 'POST',
            headers,
            signal,
        });
        if (!res.ok) {
            throw new StreamAPIError({
                status: res.response.status,
                message: `Complete request failed: ${res.response.status} ${res.response.statusText}`,
            });
        }
    }
    async abort(_reason) {
        this.closed = true;
        // For append-based streaming, abort is a no-op since each request is independent
        // The stream will simply be incomplete if not all chunks were sent
    }
}
const StreamNamespaceInvalidError = StructuredError('StreamNamespaceInvalidError', 'Stream namespace must be between 1 and 254 characters');
const StreamLimitInvalidError = StructuredError('StreamLimitInvalidError', 'Stream limit must be greater than 0 and less than or equal to 1000');
const StreamIDRequiredError = StructuredError('StreamIDRequiredError', 'Stream id is required and must be a non-empty string');
export class StreamStorageService {
    #adapter;
    #baseUrl;
    constructor(baseUrl, adapter) {
        this.#adapter = adapter;
        this.#baseUrl = baseUrl;
    }
    async create(namespace, props) {
        if (!namespace || namespace.length < 1 || namespace.length > 254) {
            throw new StreamNamespaceInvalidError();
        }
        const url = this.#baseUrl;
        const signal = AbortSignal.timeout(30_000); // 30s timeout for Neon cold starts;
        const attributes = {
            namespace,
        };
        if (!props?.contentType) {
            props = props ?? {};
            props.contentType = 'application/octet-stream';
        }
        if (props?.metadata) {
            attributes['metadata'] = JSON.stringify(props.metadata);
        }
        if (props?.contentType) {
            attributes['stream.content_type'] = props.contentType;
        }
        // Map namespace to name for the API (backend still uses 'name')
        // Note: Pulse expects content-type in the headers object, not as a separate contentType field
        const headers = {};
        if (props?.contentType) {
            headers['content-type'] = props.contentType;
        }
        const body = JSON.stringify({
            name: namespace,
            ...(props?.metadata && { metadata: props.metadata }),
            ...(Object.keys(headers).length > 0 && { headers }),
            // TTL handling: only include if explicitly provided
            // null or 0 = no expiration, positive = TTL in seconds
            // undefined = not sent, server uses default (30 days)
            ...(props?.ttl !== undefined && { ttl: props.ttl === null ? 0 : props.ttl }),
        });
        const res = await this.#adapter.invoke(url, {
            method: 'POST',
            body,
            contentType: 'application/json',
            signal,
            telemetry: {
                name: 'agentuity.stream.create',
                attributes,
            },
        });
        if (res.ok) {
            const streamUrl = buildUrl(this.#baseUrl, res.data.id);
            const sink = new UnderlyingSinkState(streamUrl, this.#adapter, props);
            // Initialize the sink (start the PUT request) and get the writable stream
            const writable = await sink.start();
            const stream = new StreamImpl(res.data.id, streamUrl, sink.compressionEnabled, sink, writable, this.#adapter);
            return stream;
        }
        throw await toServiceException('POST', url, res.response);
    }
    async list(params) {
        const attributes = {};
        if (params?.limit !== undefined) {
            if (params.limit <= 0 || params.limit > 1000) {
                throw new StreamLimitInvalidError();
            }
            attributes['limit'] = String(params.limit);
        }
        if (params?.offset !== undefined) {
            attributes['offset'] = String(params.offset);
        }
        if (params?.namespace) {
            attributes['namespace'] = params.namespace;
        }
        if (params?.name) {
            attributes['name'] = params.name;
        }
        if (params?.metadata) {
            attributes['metadata'] = JSON.stringify(params.metadata);
        }
        // Map namespace to name for the API (backend still uses 'name')
        const requestBody = {};
        if (params?.namespace) {
            requestBody.name = params.namespace;
        }
        else if (params?.name) {
            requestBody.name = params.name;
        }
        if (params?.metadata) {
            requestBody.metadata = params.metadata;
        }
        if (params?.limit) {
            requestBody.limit = params.limit;
        }
        if (params?.offset) {
            requestBody.offset = params.offset;
        }
        if (params?.sort) {
            requestBody.sort = params.sort;
        }
        if (params?.direction) {
            requestBody.direction = params.direction;
        }
        const signal = AbortSignal.timeout(30_000);
        const url = buildUrl(this.#baseUrl, 'list');
        const res = await this.#adapter.invoke(url, {
            method: 'POST',
            signal,
            body: JSON.stringify(requestBody),
            contentType: 'application/json',
            telemetry: {
                name: 'agentuity.stream.list',
                attributes,
            },
        });
        if (res.ok) {
            // Transform snake_case to camelCase and map name to namespace
            return {
                success: res.data.success,
                message: res.data.message,
                streams: res.data.streams.map((s) => ({
                    id: s.id,
                    namespace: s.name,
                    metadata: s.metadata ?? {},
                    url: s.url,
                    sizeBytes: s.size_bytes,
                    expiresAt: s.expires_at ?? null,
                })),
                total: res.data.total,
            };
        }
        throw await toServiceException('POST', url, res.response);
    }
    async get(id) {
        if (!id || typeof id !== 'string' || id.trim().length === 0) {
            throw new StreamIDRequiredError();
        }
        const signal = AbortSignal.timeout(30_000);
        const url = buildUrl(this.#baseUrl, id, 'info');
        const res = await this.#adapter.invoke(url, {
            method: 'POST',
            signal,
            body: '{}',
            contentType: 'application/json',
            telemetry: {
                name: 'agentuity.stream.get',
                attributes: {
                    'stream.id': id,
                },
            },
        });
        if (res.ok) {
            // Map name to namespace for the SDK interface
            return {
                id: res.data.id,
                namespace: res.data.name,
                metadata: res.data.metadata ?? {},
                url: res.data.url,
                sizeBytes: res.data.size_bytes,
                expiresAt: res.data.expires_at ?? null,
            };
        }
        throw await toServiceException('POST', url, res.response);
    }
    async download(id) {
        if (!id || typeof id !== 'string' || id.trim().length === 0) {
            throw new StreamIDRequiredError();
        }
        const signal = AbortSignal.timeout(300_000); // 5 minutes for download
        const url = buildUrl(this.#baseUrl, id);
        const res = await this.#adapter.invoke(url, {
            method: 'GET',
            signal,
            binary: true,
            telemetry: {
                name: 'agentuity.stream.download',
                attributes: {
                    'stream.id': id,
                },
            },
        });
        if (res.ok && res.response.body) {
            return res.response.body;
        }
        throw await toServiceException('GET', url, res.response);
    }
    async delete(id) {
        if (!id || typeof id !== 'string' || id.trim().length === 0) {
            throw new StreamIDRequiredError();
        }
        const signal = AbortSignal.timeout(30_000);
        const url = buildUrl(this.#baseUrl, id);
        const res = await this.#adapter.invoke(url, {
            method: 'DELETE',
            signal,
            telemetry: {
                name: 'agentuity.stream.delete',
                attributes: {
                    'stream.id': id,
                },
            },
        });
        if (res.ok) {
            return;
        }
        throw await toServiceException('DELETE', url, res.response);
    }
}
//# sourceMappingURL=service.js.map