import { describe, expect, it, beforeEach, vi } from 'vitest';
import { S3Storage } from '../s3-storage.js';
import { SessionError } from '../../errors.js';
import { createTestSnapshot, createTestManifest, createTestScope } from '../../__fixtures__/mock-storage-provider.js';
vi.mock('@aws-sdk/client-s3', () => ({
    S3Client: vi.fn().mockImplementation(function () {
        return {
            send: vi.fn(),
            config: {},
        };
    }),
    PutObjectCommand: vi.fn().mockImplementation(function (input) {
        return { input };
    }),
    GetObjectCommand: vi.fn().mockImplementation(function (input) {
        return { input };
    }),
    ListObjectsV2Command: vi.fn().mockImplementation(function (input) {
        return { input };
    }),
    DeleteObjectsCommand: vi.fn().mockImplementation(function (input) {
        return { input };
    }),
}));
const SCOPE_ID = 'test-agent';
describe('S3Storage', () => {
    let storage;
    let mockS3Client;
    beforeEach(() => {
        vi.clearAllMocks();
        storage = new S3Storage({ bucket: 'test-bucket', region: 'us-east-1' });
        mockS3Client = storage._s3;
    });
    describe('constructor', () => {
        describe('S3SnapshotStorage_When_ValidConfig_Then_CreatesInstance', () => {
            it('stores bucket and region configuration', () => {
                const instance = new S3Storage({ bucket: 'test-bucket', region: 'us-west-2' });
                expect(instance._bucket).toBe('test-bucket');
                expect(instance._s3).toBeDefined();
            });
            it('stores prefix when provided', () => {
                const instance = new S3Storage({ bucket: 'test-bucket', prefix: 'my-prefix', region: 'us-east-1' });
                expect(instance._prefix).toBe('my-prefix');
            });
            it('uses provided S3 client instead of creating new one', () => {
                const customClient = { send: vi.fn() };
                const instance = new S3Storage({ bucket: 'test-bucket', s3Client: customClient });
                expect(instance._s3).toBe(customClient);
            });
            it('throws error when both s3Client and region are provided', () => {
                const config = { bucket: 'test-bucket', region: 'us-west-2', s3Client: { send: vi.fn() } };
                expect(() => new S3Storage(config)).toThrow(SessionError);
                expect(() => new S3Storage(config)).toThrow('Cannot specify both s3Client and region');
            });
        });
    });
    describe('saveSnapshot', () => {
        describe('S3SnapshotStorage_When_saveSnapshot_Then_PutsObjects', () => {
            it('saves snapshot to S3 history', async () => {
                const location = { sessionId: 'test-session', scope: createTestScope(), scopeId: SCOPE_ID };
                const snapshot = createTestSnapshot();
                mockS3Client.send.mockResolvedValue({});
                await storage.saveSnapshot({ location, snapshotId: '1', isLatest: false, snapshot });
                expect(mockS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: {
                        Bucket: 'test-bucket',
                        Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_1.json`,
                        Body: JSON.stringify(snapshot, null, 2),
                        ContentType: 'application/json',
                    },
                }));
            });
            it('saves snapshot as latest when isLatest is true', async () => {
                const location = { sessionId: 'test-session', scope: createTestScope(), scopeId: SCOPE_ID };
                const snapshot = createTestSnapshot();
                mockS3Client.send.mockResolvedValue({});
                await storage.saveSnapshot({ location, snapshotId: '1', isLatest: true, snapshot });
                expect(mockS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: expect.objectContaining({
                        Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/snapshot_latest.json`,
                    }),
                }));
            });
            it('uses prefix when configured', async () => {
                const storageWithPrefix = new S3Storage({ bucket: 'test-bucket', prefix: 'my-app', region: 'us-east-1' });
                const mockPrefixS3Client = storageWithPrefix._s3;
                const location = { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID };
                const snapshot = createTestSnapshot();
                mockPrefixS3Client.send.mockResolvedValue({});
                await storageWithPrefix.saveSnapshot({ location, snapshotId: '1', isLatest: false, snapshot });
                expect(mockPrefixS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: expect.objectContaining({
                        Key: `my-app/test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_1.json`,
                    }),
                }));
            });
        });
        describe('S3SnapshotStorage_When_saveSnapshotFails_Then_ThrowsSessionError', () => {
            it('throws SessionError when S3 put fails', async () => {
                const location = { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID };
                const snapshot = createTestSnapshot();
                mockS3Client.send.mockRejectedValue(new Error('S3 error'));
                await expect(storage.saveSnapshot({ location, snapshotId: '1', isLatest: false, snapshot })).rejects.toThrow('Failed to write S3 object');
            });
        });
        describe('S3SnapshotStorage_When_MultiAgentScope_Then_SavesCorrectly', () => {
            it('saves multi-agent snapshot to correct S3 key', async () => {
                const location = { sessionId: 'multi-session', scope: 'multiAgent', scopeId: 'graph-1' };
                const snapshot = createTestSnapshot({ scope: 'multiAgent' });
                mockS3Client.send.mockResolvedValue({});
                await storage.saveSnapshot({ location, snapshotId: '1', isLatest: true, snapshot });
                expect(mockS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: expect.objectContaining({
                        Key: 'multi-session/scopes/multiAgent/graph-1/snapshots/snapshot_latest.json',
                    }),
                }));
            });
        });
    });
    describe('loadSnapshot', () => {
        describe('S3SnapshotStorage_When_LoadLatestSnapshot_Then_ReturnsSnapshot', () => {
            it('loads latest snapshot when snapshotId is undefined', async () => {
                const location = { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID };
                const snapshot = createTestSnapshot();
                mockS3Client.send.mockResolvedValue({
                    Body: { transformToString: () => Promise.resolve(JSON.stringify(snapshot)) },
                });
                const result = await storage.loadSnapshot({ location });
                expect(result).toEqual(snapshot);
                expect(mockS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: {
                        Bucket: 'test-bucket',
                        Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/snapshot_latest.json`,
                    },
                }));
            });
        });
        describe('S3SnapshotStorage_When_LoadSpecificSnapshot_Then_ReturnsSnapshot', () => {
            it('loads specific snapshot by ID', async () => {
                const location = { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID };
                const snapshot = createTestSnapshot();
                mockS3Client.send.mockResolvedValue({
                    Body: { transformToString: () => Promise.resolve(JSON.stringify(snapshot)) },
                });
                const result = await storage.loadSnapshot({ location, snapshotId: '5' });
                expect(result).toEqual(snapshot);
                expect(mockS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: expect.objectContaining({
                        Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_5.json`,
                    }),
                }));
            });
        });
        describe('S3SnapshotStorage_When_SnapshotNotFound_Then_ReturnsNull', () => {
            it('returns null when S3 object does not exist', async () => {
                const noSuchKeyError = Object.assign(new Error('NoSuchKey'), { name: 'NoSuchKey' });
                mockS3Client.send.mockRejectedValue(noSuchKeyError);
                const result = await storage.loadSnapshot({
                    location: { sessionId: 'nonexistent', scope: 'agent', scopeId: SCOPE_ID },
                });
                expect(result).toBeNull();
            });
            it('returns null when S3 response has no body', async () => {
                mockS3Client.send.mockResolvedValue({ Body: null });
                const result = await storage.loadSnapshot({
                    location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID },
                });
                expect(result).toBeNull();
            });
            it('returns null when S3 response body is empty', async () => {
                mockS3Client.send.mockResolvedValue({ Body: { transformToString: () => Promise.resolve('') } });
                const result = await storage.loadSnapshot({
                    location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID },
                });
                expect(result).toBeNull();
            });
        });
        describe('S3SnapshotStorage_When_InvalidJSON_Then_ThrowsSessionError', () => {
            it('throws SessionError when JSON is invalid', async () => {
                mockS3Client.send.mockResolvedValue({
                    Body: { transformToString: () => Promise.resolve('invalid json') },
                });
                await expect(storage.loadSnapshot({ location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID } })).rejects.toThrow('Invalid JSON in S3 object');
            });
        });
        describe('S3SnapshotStorage_When_S3Error_Then_ThrowsSessionError', () => {
            it('throws SessionError when S3 get fails', async () => {
                mockS3Client.send.mockRejectedValue(new Error('S3 error'));
                await expect(storage.loadSnapshot({ location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID } })).rejects.toThrow('S3 error reading');
            });
        });
    });
    describe('listSnapshotIds', () => {
        describe('S3SnapshotStorage_When_listSnapshots_Then_ReturnsOrderedIds', () => {
            it('returns sorted snapshot IDs', async () => {
                const ids = [
                    '019c9bf1-14e5-7eef-96fb-cc07ae54210f',
                    '019c9bf1-1d34-7eef-96fb-d1be20fd7bbd',
                    '019c9bf1-24bb-7eef-96fb-ddcc943cd859',
                ];
                // S3 returns objects in lexicographic key order — mock reflects that contract
                mockS3Client.send.mockResolvedValue({
                    Contents: [
                        { Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_${ids[0]}.json` },
                        { Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_${ids[1]}.json` },
                        { Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_${ids[2]}.json` },
                    ],
                });
                const result = await storage.listSnapshotIds({
                    location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID },
                });
                expect(result).toEqual(ids);
                expect(mockS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: expect.objectContaining({
                        Bucket: 'test-bucket',
                        Prefix: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/`,
                        MaxKeys: 1000,
                    }),
                }));
            });
            it('returns empty array when no objects exist', async () => {
                mockS3Client.send.mockResolvedValue({ Contents: [] });
                const result = await storage.listSnapshotIds({
                    location: { sessionId: 'empty-session', scope: 'agent', scopeId: SCOPE_ID },
                });
                expect(result).toEqual([]);
            });
            it('ignores non-snapshot objects', async () => {
                const id1 = '019c9bf1-14e5-7eef-96fb-cc07ae54210f';
                const id2 = '019c9bf1-1d34-7eef-96fb-d1be20fd7bbd';
                mockS3Client.send.mockResolvedValue({
                    Contents: [
                        { Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_${id1}.json` },
                        { Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/other-file.txt` },
                        { Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_${id2}.json` },
                    ],
                });
                const result = await storage.listSnapshotIds({
                    location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID },
                });
                expect(result).toEqual([id1, id2]);
            });
            it('handles objects without Key property', async () => {
                const id1 = '019c9bf1-14e5-7eef-96fb-cc07ae54210f';
                const id2 = '019c9bf1-1d34-7eef-96fb-d1be20fd7bbd';
                mockS3Client.send.mockResolvedValue({
                    Contents: [
                        { Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_${id1}.json` },
                        {},
                        { Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_${id2}.json` },
                    ],
                });
                const result = await storage.listSnapshotIds({
                    location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID },
                });
                expect(result).toEqual([id1, id2]);
            });
            it('filters by startAfter for pagination', async () => {
                const ids = [
                    '019c9bf1-14e5-7eef-96fb-cc07ae54210f',
                    '019c9bf1-1d34-7eef-96fb-d1be20fd7bbd',
                    '019c9bf1-24bb-7eef-96fb-ddcc943cd859',
                ];
                // Simulate S3 server-side StartAfter: only return objects after ids[0]
                mockS3Client.send.mockResolvedValue({
                    Contents: [ids[1], ids[2]].map((id) => ({
                        Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_${id}.json`,
                    })),
                });
                const result = await storage.listSnapshotIds({
                    location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID },
                    startAfter: ids[0],
                });
                expect(result).toEqual([ids[1], ids[2]]);
                expect(mockS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: expect.objectContaining({
                        StartAfter: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_${ids[0]}.json`,
                    }),
                }));
            });
            it('limits results when limit is provided', async () => {
                const ids = [
                    '019c9bf1-14e5-7eef-96fb-cc07ae54210f',
                    '019c9bf1-1d34-7eef-96fb-d1be20fd7bbd',
                    '019c9bf1-24bb-7eef-96fb-ddcc943cd859',
                ];
                mockS3Client.send.mockResolvedValue({
                    Contents: ids.map((id) => ({
                        Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_${id}.json`,
                    })),
                });
                const result = await storage.listSnapshotIds({
                    location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID },
                    limit: 2,
                });
                expect(result).toEqual([ids[0], ids[1]]);
            });
            it('combines startAfter and limit', async () => {
                const ids = [
                    '019c9bf1-14e5-7eef-96fb-cc07ae54210f',
                    '019c9bf1-1d34-7eef-96fb-d1be20fd7bbd',
                    '019c9bf1-24bb-7eef-96fb-ddcc943cd859',
                ];
                // Simulate S3 server-side StartAfter: only return objects after ids[0]
                mockS3Client.send.mockResolvedValue({
                    Contents: [ids[1], ids[2]].map((id) => ({
                        Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/immutable_history/snapshot_${id}.json`,
                    })),
                });
                const result = await storage.listSnapshotIds({
                    location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID },
                    startAfter: ids[0],
                    limit: 1,
                });
                expect(result).toEqual([ids[1]]);
            });
        });
        describe('S3SnapshotStorage_When_ListObjectsFails_Then_ThrowsSessionError', () => {
            it('throws SessionError when S3 list fails', async () => {
                mockS3Client.send.mockRejectedValue(new Error('S3 list error'));
                await expect(storage.listSnapshotIds({ location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID } })).rejects.toThrow('Failed to list snapshots for session test-session');
            });
        });
    });
    describe('deleteSession', () => {
        describe('S3SnapshotStorage_When_DeleteSession_Then_DeletesAllObjects', () => {
            it('deletes all objects under the session prefix', async () => {
                mockS3Client.send
                    .mockResolvedValueOnce({
                    Contents: [
                        { Key: 'test-session/scopes/agent/agent-1/snapshots/snapshot_latest.json' },
                        { Key: 'test-session/scopes/agent/agent-1/snapshots/immutable_history/snapshot_abc.json' },
                    ],
                    IsTruncated: false,
                })
                    .mockResolvedValueOnce({});
                await storage.deleteSession({ sessionId: 'test-session' });
                expect(mockS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: expect.objectContaining({
                        Bucket: 'test-bucket',
                        Prefix: 'test-session/',
                    }),
                }));
                expect(mockS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: {
                        Bucket: 'test-bucket',
                        Delete: {
                            Objects: [
                                { Key: 'test-session/scopes/agent/agent-1/snapshots/snapshot_latest.json' },
                                { Key: 'test-session/scopes/agent/agent-1/snapshots/immutable_history/snapshot_abc.json' },
                            ],
                        },
                    },
                }));
            });
            it('paginates when session has more than 1000 objects', async () => {
                mockS3Client.send
                    .mockResolvedValueOnce({
                    Contents: [{ Key: 'test-session/page-1-object.json' }],
                    IsTruncated: true,
                    NextContinuationToken: 'token-1',
                })
                    .mockResolvedValueOnce({})
                    .mockResolvedValueOnce({
                    Contents: [{ Key: 'test-session/page-2-object.json' }],
                    IsTruncated: false,
                })
                    .mockResolvedValueOnce({});
                await storage.deleteSession({ sessionId: 'test-session' });
                expect(mockS3Client.send).toHaveBeenCalledTimes(4);
            });
            it('no-ops when session has no objects', async () => {
                mockS3Client.send.mockResolvedValueOnce({ Contents: [], IsTruncated: false });
                await storage.deleteSession({ sessionId: 'empty-session' });
                expect(mockS3Client.send).toHaveBeenCalledTimes(1);
            });
            it('uses prefix when configured', async () => {
                const storageWithPrefix = new S3Storage({ bucket: 'test-bucket', prefix: 'my-app', region: 'us-east-1' });
                const mockPrefixS3Client = storageWithPrefix._s3;
                mockPrefixS3Client.send.mockResolvedValueOnce({ Contents: [], IsTruncated: false });
                await storageWithPrefix.deleteSession({ sessionId: 'test-session' });
                expect(mockPrefixS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: expect.objectContaining({ Prefix: 'my-app/test-session/' }),
                }));
            });
        });
        describe('S3SnapshotStorage_When_DeleteSessionFails_Then_ThrowsSessionError', () => {
            it('throws SessionError when S3 list fails during delete', async () => {
                mockS3Client.send.mockRejectedValue(new Error('S3 error'));
                await expect(storage.deleteSession({ sessionId: 'test-session' })).rejects.toThrow('Failed to delete session test-session');
            });
        });
    });
    describe('loadManifest', () => {
        describe('S3SnapshotStorage_When_LoadManifest_Then_ReturnsManifest', () => {
            it('loads existing manifest', async () => {
                const location = { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID };
                const manifest = createTestManifest();
                mockS3Client.send.mockResolvedValue({
                    Body: { transformToString: () => Promise.resolve(JSON.stringify(manifest)) },
                });
                const result = await storage.loadManifest({ location });
                expect(result).toEqual(manifest);
                expect(mockS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: expect.objectContaining({
                        Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/manifest.json`,
                    }),
                }));
            });
        });
        describe('S3SnapshotStorage_When_ManifestNotFound_Then_ReturnsDefault', () => {
            it('returns default manifest when S3 object does not exist', async () => {
                const noSuchKeyError = Object.assign(new Error('NoSuchKey'), { name: 'NoSuchKey' });
                mockS3Client.send.mockRejectedValue(noSuchKeyError);
                const result = await storage.loadManifest({
                    location: { sessionId: 'nonexistent', scope: 'agent', scopeId: SCOPE_ID },
                });
                expect(result).toEqual({
                    schemaVersion: '1.0',
                    updatedAt: expect.any(String),
                });
            });
        });
        describe('S3SnapshotStorage_When_InvalidManifestJSON_Then_ThrowsSessionError', () => {
            it('throws SessionError when manifest JSON is invalid', async () => {
                mockS3Client.send.mockResolvedValue({
                    Body: { transformToString: () => Promise.resolve('invalid json') },
                });
                await expect(storage.loadManifest({ location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID } })).rejects.toThrow(SessionError);
            });
        });
    });
    describe('saveManifest', () => {
        describe('S3SnapshotStorage_When_SaveManifest_Then_PutsObject', () => {
            it('saves manifest to S3', async () => {
                const location = { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID };
                const manifest = createTestManifest();
                mockS3Client.send.mockResolvedValue({});
                await storage.saveManifest({ location, manifest });
                expect(mockS3Client.send).toHaveBeenCalledWith(expect.objectContaining({
                    input: {
                        Bucket: 'test-bucket',
                        Key: `test-session/scopes/agent/${SCOPE_ID}/snapshots/manifest.json`,
                        Body: JSON.stringify(manifest, null, 2),
                        ContentType: 'application/json',
                    },
                }));
            });
        });
        describe('S3SnapshotStorage_When_SaveManifestFails_Then_ThrowsSessionError', () => {
            it('throws SessionError when S3 put fails', async () => {
                mockS3Client.send.mockRejectedValue(new Error('S3 error'));
                await expect(storage.saveManifest({
                    location: { sessionId: 'test-session', scope: 'agent', scopeId: SCOPE_ID },
                    manifest: createTestManifest(),
                })).rejects.toThrow(SessionError);
            });
        });
    });
});
//# sourceMappingURL=s3-storage.test.js.map