/**
 * Storage backends for offloaded tool result content.
 *
 * This module defines the {@link Storage} interface and provides three built-in
 * implementations: {@link InMemoryStorage}, {@link FileStorage}, and {@link S3Storage}.
 * Each content block from a tool result is stored individually with its content type preserved.
 */
import { logger } from '../../logging/logger.js';
function sanitizeId(rawId) {
    return rawId
        .replace(/\.\./g, '_')
        .replace(/[/\\]/g, '_')
        .replace(/[^\w\-.]/g, '_');
}
/**
 * Remove any trailing `/` characters from a path-like string.
 *
 * Implemented as a linear scan rather than a `/\/+$/` replacement so that
 * inputs with long runs of slashes are handled in linear time.
 */
function stripTrailingSlashes(value) {
    let end = value.length;
    while (end > 0 && value[end - 1] === '/') {
        end--;
    }
    return value.slice(0, end);
}
/**
 * In-memory storage backend.
 *
 * Useful for testing and serverless environments where disk access is not available.
 * Supports turn-based eviction: entries not accessed (stored or retrieved) within
 * `evictAfterTurns` agent loop cycles are automatically removed when the plugin
 * calls `_evict`. Eviction is enabled by default (20 cycles). Pass `null` to disable.
 *
 * Note: content does not survive process restarts. For multi-session persistence,
 * use {@link FileStorage} or {@link S3Storage}. Each agent should use its own
 * `InMemoryStorage` instance — sharing one across multiple agents is not supported
 * when eviction is enabled.
 *
 * Evicted entries are permanently deleted from memory. The agent will receive
 * an error if it attempts to retrieve evicted content.
 *
 * @param evictAfterTurns - Cycles of inactivity before eviction. Defaults to 20. `null` disables.
 */
export class InMemoryStorage {
    _store = new Map();
    _counter = 0;
    _currentCycle = 0;
    _evictAfterTurns;
    _boundAgent = null;
    static DEFAULT_EVICT_AFTER_TURNS = 20;
    constructor(evictAfterTurns = InMemoryStorage.DEFAULT_EVICT_AFTER_TURNS) {
        if (evictAfterTurns !== null && evictAfterTurns < 1) {
            throw new Error('evictAfterTurns must be a positive integer');
        }
        this._evictAfterTurns = evictAfterTurns;
    }
    /** {@inheritdoc} */
    async store(key, content, contentType = 'text/plain') {
        this._counter++;
        const reference = `mem_${this._counter}_${key}`;
        this._store.set(reference, { content, contentType, lastAccessedCycle: this._currentCycle });
        return reference;
    }
    /** {@inheritdoc} */
    async retrieve(reference) {
        const entry = this._store.get(reference);
        if (!entry) {
            throw new Error(`Reference not found: ${reference}`);
        }
        entry.lastAccessedCycle = this._currentCycle;
        return { content: entry.content, contentType: entry.contentType };
    }
    /**
     * Claim this storage for a single agent. Throws if already bound to a different agent.
     * @internal
     */
    _bind(agent) {
        if (this._boundAgent === null) {
            this._boundAgent = new WeakRef(agent);
        }
        else if (this._boundAgent.deref() !== agent) {
            throw new Error('InMemoryStorage cannot be shared across multiple agents. ' +
                'Use a separate InMemoryStorage instance per agent.');
        }
    }
    /**
     * Update current cycle and evict stale entries.
     * Called by the ContextOffloader plugin on each BeforeModelCallEvent.
     * @internal
     */
    _evict(cycle) {
        this._currentCycle = cycle;
        if (this._evictAfterTurns === null)
            return;
        const threshold = cycle - this._evictAfterTurns;
        let evicted = 0;
        for (const [ref, entry] of this._store) {
            if (entry.lastAccessedCycle < threshold) {
                this._store.delete(ref);
                evicted++;
            }
        }
        if (evicted > 0) {
            logger.debug(`evicted=<${evicted}>, cycle=<${cycle}> | stale entries removed`);
        }
    }
    /** Remove all stored content. */
    clear() {
        this._store.clear();
    }
}
export class FileStorage {
    static METADATA_FILE = '.metadata.json';
    _artifactDir;
    _sandbox;
    _counter = 0;
    _contentTypes = {};
    _metadataLoaded = false;
    _metadataWriteChain = Promise.resolve();
    constructor(options = './artifacts') {
        if (typeof options === 'string') {
            this._artifactDir = options;
            this._sandbox = undefined;
        }
        else {
            this._artifactDir = options.artifactDir ?? './artifacts';
            this._sandbox = options.sandbox;
        }
    }
    /**
     * Return a storage instance bound to the provided sandbox.
     *
     * Instances constructed with an explicit sandbox keep using that sandbox. Detached
     * instances produce a new storage object so a shared ContextOffloader can isolate
     * artifacts for each agent sandbox.
     *
     * @param sandbox - Sandbox to use for the returned storage instance.
     */
    forSandbox(sandbox) {
        if (this._sandbox)
            return this;
        return new FileStorage({ artifactDir: this._artifactDir, sandbox });
    }
    static _extensionFor(contentType) {
        if (contentType === 'text/plain')
            return '.txt';
        return `.${contentType.split('/').pop()}`;
    }
    _artifactPath(filename) {
        return `${this._artifactDir.replace(/\/+$/, '')}/${filename}`;
    }
    async _ensureDir() {
        const fs = await import('node:fs/promises');
        await fs.mkdir(this._artifactDir, { recursive: true });
        if (!this._metadataLoaded) {
            this._contentTypes = await this._loadMetadata(fs);
            this._metadataLoaded = true;
        }
        return fs;
    }
    async _ensureSandbox() {
        const sandbox = this._sandbox;
        if (!sandbox) {
            throw new Error('FileStorage requires a Sandbox to be configured on the storage instance.');
        }
        if (!this._metadataLoaded) {
            this._contentTypes = await this._loadSandboxMetadata(sandbox);
            this._metadataLoaded = true;
        }
        return sandbox;
    }
    async _loadMetadata(fs) {
        const path = await import('node:path');
        const metadataPath = path.join(this._artifactDir, FileStorage.METADATA_FILE);
        try {
            const raw = await fs.readFile(metadataPath, 'utf-8');
            return JSON.parse(raw);
        }
        catch {
            return {};
        }
    }
    async _loadSandboxMetadata(sandbox) {
        try {
            const raw = await sandbox.readText(this._artifactPath(FileStorage.METADATA_FILE));
            return JSON.parse(raw);
        }
        catch {
            return {};
        }
    }
    async _saveMetadata(fs) {
        const path = await import('node:path');
        const metadataPath = path.join(this._artifactDir, FileStorage.METADATA_FILE);
        await fs.writeFile(metadataPath, JSON.stringify(this._contentTypes), 'utf-8');
    }
    async _saveSandboxMetadata(sandbox) {
        await sandbox.writeText(this._artifactPath(FileStorage.METADATA_FILE), JSON.stringify(this._contentTypes));
    }
    /** {@inheritdoc} */
    async store(key, content, contentType = 'text/plain') {
        if (this._sandbox) {
            const sandbox = await this._ensureSandbox();
            const filename = `${Date.now()}_${++this._counter}_${sanitizeId(key)}${FileStorage._extensionFor(contentType)}`;
            const filePath = this._artifactPath(filename);
            this._contentTypes[filename] = contentType;
            this._metadataWriteChain = this._metadataWriteChain.then(() => this._saveSandboxMetadata(sandbox));
            await this._metadataWriteChain;
            await sandbox.writeFile(filePath, content);
            return filePath;
        }
        const fs = await this._ensureDir();
        const path = await import('node:path');
        const sanitizedKey = sanitizeId(key);
        const timestampMs = Date.now();
        this._counter++;
        const ext = FileStorage._extensionFor(contentType);
        const filename = `${timestampMs}_${this._counter}_${sanitizedKey}${ext}`;
        this._contentTypes[filename] = contentType;
        this._metadataWriteChain = this._metadataWriteChain.then(() => this._saveMetadata(fs));
        await this._metadataWriteChain;
        const filePath = path.join(this._artifactDir, filename);
        await fs.writeFile(filePath, content);
        return filePath;
    }
    /** {@inheritdoc} */
    async retrieve(reference) {
        if (this._sandbox) {
            const sandbox = await this._ensureSandbox();
            if (!reference.startsWith(`${this._artifactDir.replace(/\/+$/, '')}/`) || reference.includes('..')) {
                throw new Error(`Reference not found: ${reference}`);
            }
            const filename = reference.split('/').pop();
            try {
                const content = await sandbox.readFile(reference);
                const contentType = this._contentTypes[filename] ?? 'application/octet-stream';
                return { content, contentType };
            }
            catch {
                throw new Error(`Reference not found: ${reference}`);
            }
        }
        const fs = await this._ensureDir();
        const path = await import('node:path');
        const filePath = path.resolve(this._artifactDir, reference);
        const resolvedDir = path.resolve(this._artifactDir);
        if (!filePath.startsWith(resolvedDir)) {
            throw new Error(`Reference not found: ${reference}`);
        }
        const filename = path.basename(filePath);
        try {
            const content = await fs.readFile(filePath);
            const contentType = this._contentTypes[filename] ?? 'application/octet-stream';
            return { content: new Uint8Array(content), contentType };
        }
        catch {
            throw new Error(`Reference not found: ${reference}`);
        }
    }
}
/**
 * S3-based storage backend.
 *
 * Stores offloaded content as S3 objects. Content type is preserved as S3 object metadata.
 * References are `s3://` URIs for direct access via AWS CLI or SDK.
 *
 * @param bucket - S3 bucket name
 * @param options - Optional configuration (prefix, region, pre-configured S3Client)
 */
export class S3Storage {
    _bucket;
    _prefix;
    _client;
    _region;
    _counter = 0;
    constructor(bucket, options) {
        this._bucket = bucket;
        this._prefix = options?.prefix ? stripTrailingSlashes(options.prefix) + '/' : '';
        this._client = options?.s3Client;
        this._region = options?.region ?? 'us-east-1';
    }
    async _getClient() {
        if (this._client)
            return this._client;
        const { S3Client } = await import('@aws-sdk/client-s3');
        this._client = new S3Client({ region: this._region });
        return this._client;
    }
    /** {@inheritdoc} */
    async store(key, content, contentType = 'text/plain') {
        const client = await this._getClient();
        const { PutObjectCommand } = await import('@aws-sdk/client-s3');
        const sanitizedKey = sanitizeId(key);
        const timestampMs = Date.now();
        this._counter++;
        const s3Key = `${this._prefix}${timestampMs}_${this._counter}_${sanitizedKey}`;
        await client.send(new PutObjectCommand({
            Bucket: this._bucket,
            Key: s3Key,
            Body: content,
            ContentType: contentType,
        }));
        return `s3://${this._bucket}/${s3Key}`;
    }
    /** {@inheritdoc} */
    async retrieve(reference) {
        const client = await this._getClient();
        const { GetObjectCommand } = await import('@aws-sdk/client-s3');
        // Accept both s3:// URIs and raw keys
        let s3Key = reference;
        const uriMatch = reference.match(/^s3:\/\/([^/]+)\/(.+)$/);
        if (uriMatch?.[1] && uriMatch[2]) {
            if (uriMatch[1] !== this._bucket) {
                throw new Error(`Reference not found: ${reference} (bucket mismatch)`);
            }
            s3Key = uriMatch[2];
        }
        try {
            const response = await client.send(new GetObjectCommand({
                Bucket: this._bucket,
                Key: s3Key,
            }));
            const body = await response.Body?.transformToByteArray();
            if (!body)
                throw new Error(`Reference not found: ${reference}`);
            const contentType = response.ContentType ?? 'application/octet-stream';
            return { content: new Uint8Array(body), contentType };
        }
        catch (error) {
            if (error instanceof Error && error.name === 'NoSuchKey') {
                throw new Error(`Reference not found: ${reference}`, { cause: error });
            }
            throw error;
        }
    }
}
//# sourceMappingURL=storage.js.map