import { StructuredError } from '../../error.ts';
import { safeStringify } from '../../json.ts';
import { buildUrl, toServiceException } from '../_util.ts';
import type { FetchAdapter } from '../adapter.ts';
import { ListParamsSchema } from '../pagination.ts';
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 type CreateStreamProps = z.infer<typeof CreateStreamPropsSchema>;

export const StreamSortFieldSchema = z.enum([
	'name',
	'created',
	'updated',
	'size',
	'count',
	'lastUsed',
]);

export type StreamSortField = z.infer<typeof StreamSortFieldSchema>;

/**
 * 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'),
});

export type ListStreamsParams = z.infer<typeof ListStreamsParamsSchema>;

/**
 * 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'),
});

export type StreamInfo = z.infer<typeof StreamInfoSchema>;

/**
 * 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)'),
});

export type ListStreamsResponse = z.infer<typeof ListStreamsResponseSchema>;

/**
 * A durable and resumable stream that can be written to and read many times.
 * The underlying stream is backed by a durable storage system and the URL
 * returned is public and guaranteed to return the same data every time it is accessed.
 * You can read from this stream internal in the agent using the getReader() method or
 * return the URL to the stream to be used externally.
 *
 * You must write and close the stream before it can be read but if you attempt to read
 * before any data is written, the reader will block until the first write occurs.
 */
export interface Stream extends WritableStream {
	/**
	 * unique stream identifier
	 */
	id: string;
	/**
	 * the unique stream url to consume the stream
	 */
	url: string;
	/**
	 * the total number of bytes written to the stream
	 */
	readonly bytesWritten: number;
	/**
	 * whether the stream is using compression
	 */
	readonly compressed: boolean;
	/**
	 * Write data to the stream.
	 *
	 * Each write call can send up to 5MB of data. For larger payloads, split the data
	 * across multiple write calls.
	 *
	 * @param chunk - The data to write (string, binary, or object that will be JSON-serialized)
	 * @throws ServiceException with status 413 if chunk exceeds 5MB
	 */
	write(chunk: string | Uint8Array | ArrayBuffer | object): Promise<void>;
	/**
	 * close the stream gracefully, handling already closed streams without error
	 */
	close(): Promise<void>;
	/**
	 * 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(): ReadableStream<Uint8Array>;
}

/**
 * Stream API for creating and managing durable, resumable data streams.
 * Streams are backed by durable storage and provide public URLs for access.
 */
export interface StreamStorage {
	/**
	 * Create a new stream for writing data that can be read multiple times
	 *
	 * @param namespace - the namespace of the stream (1-254 characters). Use namespaces to group and organize streams.
	 * @param props - optional properties including metadata, content type, compression, and TTL
	 * @returns a Promise that resolves to the created Stream
	 *
	 * @example
	 * ```typescript
	 * // Create a simple text stream (expires in 30 days by default)
	 * const stream = await streams.create('agent-logs');
	 * await stream.write('Starting agent execution\n');
	 * await stream.write('Processing data...\n');
	 * await stream.close();
	 * console.log('Stream URL:', stream.url);
	 *
	 * // Create a stream with custom TTL (expires in 1 hour)
	 * const tempStream = await streams.create('temp-data', {
	 *   ttl: 3600  // 1 hour in seconds
	 * });
	 *
	 * // Create a stream that never expires
	 * const permanentStream = await streams.create('permanent-data', {
	 *   ttl: null  // or ttl: 0
	 * });
	 *
	 * // Create a compressed JSON stream with metadata
	 * const dataStream = await streams.create('data-export', {
	 *   contentType: 'application/json',
	 *   compress: true,
	 *   metadata: { exportDate: '2024-01-15', version: '1.0' }
	 * });
	 * await dataStream.write({ records: [...] });
	 * await dataStream.close();
	 *
	 * // Read back from the stream
	 * const reader = dataStream.getReader();
	 * for await (const chunk of reader) {
	 *   console.log('Chunk:', chunk);
	 * }
	 * ```
	 */
	create(namespace: string, props?: CreateStreamProps): Promise<Stream>;

	/**
	 * Get stream metadata by ID
	 *
	 * @param id - the stream ID
	 * @returns a Promise that resolves to the stream information
	 *
	 * @example
	 * ```typescript
	 * const stream = await streams.get('stream_0199a52b06e3767dbe2f10afabb5e5e4');
	 * console.log(`Namespace: ${stream.namespace}, Size: ${stream.sizeBytes} bytes`);
	 * ```
	 */
	get(id: string): Promise<StreamInfo>;

	/**
	 * Download stream content
	 *
	 * @param id - the stream ID to download
	 * @returns a Promise that resolves to a ReadableStream of the content
	 *
	 * @example
	 * ```typescript
	 * const readable = await streams.download('stream_0199a52b06e3767dbe2f10afabb5e5e4');
	 * // Pipe to a file or process the stream
	 * ```
	 */
	download(id: string): Promise<ReadableStream<Uint8Array>>;

	/**
	 * List streams with optional filtering and pagination
	 *
	 * @param params - optional parameters for filtering and pagination
	 * @returns a Promise that resolves to the list of streams with metadata
	 *
	 * @example
	 * ```typescript
	 * // List all streams
	 * const all = await streams.list();
	 * console.log(`Found ${all.total} streams`);
	 *
	 * // Filter by namespace
	 * const logs = await streams.list({ namespace: 'agent-logs' });
	 *
	 * // Filter by metadata and paginate
	 * const filtered = await streams.list({
	 *   metadata: { type: 'export' },
	 *   limit: 50,
	 *   offset: 100
	 * });
	 *
	 * for (const stream of filtered.streams) {
	 *   console.log(`${stream.namespace}: ${stream.sizeBytes} bytes at ${stream.url}`);
	 * }
	 * ```
	 */
	list(params?: ListStreamsParams): Promise<ListStreamsResponse>;

	/**
	 * Delete a stream by its ID
	 *
	 * @param id - the stream ID to delete
	 * @returns a Promise that resolves when the stream is deleted
	 *
	 * @example
	 * ```typescript
	 * await streams.delete('stream-id-123');
	 * ```
	 */
	delete(id: string): Promise<void>;
}

const encoder = new TextEncoder();

const ReadStreamFailedError = StructuredError('ReadStreamFailedError')<{ status: number }>();

/**
 * A writable stream implementation using composition (browser-compatible)
 * This approach works across all environments since native WritableStream can't be properly extended
 */
class StreamImpl implements Stream {
	public readonly id: string;
	public readonly url: string;
	readonly #writable: WritableStream<Uint8Array>;
	#compressed: boolean;
	#adapter: FetchAdapter;
	#sink: UnderlyingSinkState;
	#closed = false;

	constructor(
		id: string,
		url: string,
		compressed: boolean,
		sink: UnderlyingSinkState,
		writable: WritableStream<Uint8Array>,
		adapter: FetchAdapter
	) {
		this.id = id;
		this.url = url;
		this.#compressed = compressed;
		this.#adapter = adapter;
		this.#sink = sink;
		this.#writable = writable;
	}

	get bytesWritten(): number {
		return this.#sink.total;
	}

	get compressed(): boolean {
		return this.#compressed;
	}

	// WritableStream interface properties
	get locked(): boolean {
		return this.#writable.locked;
	}

	/**
	 * Write data to the stream
	 */
	async write(chunk: string | Uint8Array | ArrayBuffer | object): Promise<void> {
		let binaryChunk: Uint8Array;
		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(): Promise<void> {
		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?: unknown): Promise<void> {
		return this.#writable.abort(reason);
	}

	/**
	 * Get a writer for the underlying stream
	 */
	getWriter(): WritableStreamDefaultWriter<Uint8Array> {
		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(): ReadableStream<Uint8Array> {
		const url = this.url;
		const adapter = this.#adapter;
		let ac: AbortController | null = 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?: unknown) {
				if (ac) {
					ac.abort(reason);
					ac = null;
				}
			},
		});
	}
}

const StreamAPIError = StructuredError('StreamAPIError')<{ status: number }>();

/**
 * 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(): boolean {
	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: FetchAdapter;
	total = 0;
	closed = false;
	url: string;
	props?: CreateStreamProps;
	compressionEnabled = false;
	writable: WritableStream<Uint8Array> | null = null;

	constructor(url: string, adapter: FetchAdapter, props?: CreateStreamProps) {
		this.url = url;
		this.adapter = adapter;
		this.props = props;
	}

	async start(): Promise<WritableStream<Uint8Array>> {
		// 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<Uint8Array>({
			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: Uint8Array) {
		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: Record<string, string> = {};
		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?: unknown) {
		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 implements StreamStorage {
	#adapter: FetchAdapter;
	#baseUrl: string;

	constructor(baseUrl: string, adapter: FetchAdapter) {
		this.#adapter = adapter;
		this.#baseUrl = baseUrl;
	}

	async create(namespace: string, props?: CreateStreamProps): Promise<Stream> {
		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: Record<string, string> = {
			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: Record<string, string> = {};
		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<{ id: string }>(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?: ListStreamsParams): Promise<ListStreamsResponse> {
		const attributes: Record<string, string> = {};
		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: Record<string, unknown> = {};
		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<{
			success: boolean;
			message?: string;
			streams: Array<{
				id: string;
				name: string;
				metadata: Record<string, string>;
				url: string;
				size_bytes: number;
				expires_at?: string;
			}>;
			total: number;
		}>(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: string): Promise<StreamInfo> {
		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<{
			id: string;
			name: string;
			metadata: Record<string, string>;
			url: string;
			size_bytes: number;
			expires_at?: string;
		}>(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: string): Promise<ReadableStream<Uint8Array>> {
		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: string): Promise<void> {
		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<void>(url, {
			method: 'DELETE',
			signal,
			telemetry: {
				name: 'agentuity.stream.delete',
				attributes: {
					'stream.id': id,
				},
			},
		});
		if (res.ok) {
			return;
		}
		throw await toServiceException('DELETE', url, res.response);
	}
}
