// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';

/**
 * Tests for the Protocol Implementation's data methods (findData, getData).
 * Validates that expand/populate/select parameters are correctly normalized
 * and forwarded to the underlying engine.
 */
describe('ObjectStackProtocolImplementation - Data Operations', () => {
    let protocol: ObjectStackProtocolImplementation;
    let mockEngine: any;

    beforeEach(() => {
        mockEngine = {
            find: vi.fn().mockResolvedValue([]),
            findOne: vi.fn().mockResolvedValue(null),
        };
        protocol = new ObjectStackProtocolImplementation(mockEngine);
    });

    // ═══════════════════════════════════════════════════════════════
    // findData — expand/populate normalization
    // ═══════════════════════════════════════════════════════════════

    describe('findData', () => {
        it('should normalize $expand (OData) string to expand Record', async () => {
            await protocol.findData({ object: 'order_item', query: { $expand: 'order,product' } });

            expect(mockEngine.find).toHaveBeenCalledWith(
                'order_item',
                expect.objectContaining({
                    expand: { order: { object: 'order' }, product: { object: 'product' } },
                }),
            );
            // $expand should be deleted from options
            const callArgs = mockEngine.find.mock.calls[0][1];
            expect(callArgs.$expand).toBeUndefined();
        });

        it('should normalize $expand (OData) with different fields to expand Record', async () => {
            await protocol.findData({ object: 'task', query: { $expand: 'assignee,project' } });

            expect(mockEngine.find).toHaveBeenCalledWith(
                'task',
                expect.objectContaining({
                    expand: { assignee: { object: 'assignee' }, project: { object: 'project' } },
                }),
            );
        });

        it('should normalize populate array to expand Record', async () => {
            await protocol.findData({ object: 'task', query: { populate: ['assignee'] } });

            expect(mockEngine.find).toHaveBeenCalledWith(
                'task',
                expect.objectContaining({
                    expand: { assignee: { object: 'assignee' } },
                }),
            );
        });

        it('should normalize populate string to expand Record', async () => {
            await protocol.findData({ object: 'task', query: { populate: 'assignee,project' } });

            expect(mockEngine.find).toHaveBeenCalledWith(
                'task',
                expect.objectContaining({
                    expand: { assignee: { object: 'assignee' }, project: { object: 'project' } },
                }),
            );
        });

        it('should prefer populate names over expand string when both provided', async () => {
            await protocol.findData({
                object: 'task',
                query: { populate: ['assignee'], expand: 'project' },
            });

            // populate names take precedence; the non-object expand string is
            // cleaned up first, then populate-derived names create the Record.
            const callArgs = mockEngine.find.mock.calls[0][1];
            expect(callArgs.populate).toBeUndefined();
            expect(callArgs.$expand).toBeUndefined();
            expect(callArgs.expand).toEqual({ assignee: { object: 'assignee' } });
        });

        it('should pass expand Record object through as-is', async () => {
            await protocol.findData({
                object: 'task',
                query: { expand: { owner: { object: 'owner' }, team: { object: 'team' } } },
            });

            expect(mockEngine.find).toHaveBeenCalledWith(
                'task',
                expect.objectContaining({
                    expand: { owner: { object: 'owner' }, team: { object: 'team' } },
                }),
            );
        });

        it('should normalize select string to fields array', async () => {
            await protocol.findData({ object: 'task', query: { select: 'name,status,assignee' } });

            expect(mockEngine.find).toHaveBeenCalledWith(
                'task',
                expect.objectContaining({
                    fields: ['name', 'status', 'assignee'],
                }),
            );
        });

        it('should pass numeric pagination params correctly', async () => {
            await protocol.findData({ object: 'task', query: { top: '10', skip: '20' } });

            expect(mockEngine.find).toHaveBeenCalledWith(
                'task',
                expect.objectContaining({
                    limit: 10,
                    offset: 20,
                }),
            );
        });

        it('should work with no query options', async () => {
            await protocol.findData({ object: 'task' });

            expect(mockEngine.find).toHaveBeenCalledWith('task', {});
        });

        it('should return records and standard response shape', async () => {
            mockEngine.find.mockResolvedValue([{ id: 't1', name: 'Task 1' }]);

            const result = await protocol.findData({ object: 'task', query: {} });

            expect(result).toEqual(
                expect.objectContaining({
                    object: 'task',
                    records: [{ id: 't1', name: 'Task 1' }],
                    total: 1,
                }),
            );
        });
    });

    // ═══════════════════════════════════════════════════════════════
    // getData — expand/select normalization
    // ═══════════════════════════════════════════════════════════════

    describe('getData', () => {
        it('should convert expand string to expand Record', async () => {
            mockEngine.findOne.mockResolvedValue({ id: 'oi_1', name: 'Item 1' });

            await protocol.getData({ object: 'order_item', id: 'oi_1', expand: 'order,product' });

            expect(mockEngine.findOne).toHaveBeenCalledWith(
                'order_item',
                expect.objectContaining({
                    where: { id: 'oi_1' },
                    expand: { order: { object: 'order' }, product: { object: 'product' } },
                }),
            );
        });

        it('should convert expand array to expand Record', async () => {
            mockEngine.findOne.mockResolvedValue({ id: 't1' });

            await protocol.getData({ object: 'task', id: 't1', expand: ['assignee', 'project'] });

            expect(mockEngine.findOne).toHaveBeenCalledWith(
                'task',
                expect.objectContaining({
                    where: { id: 't1' },
                    expand: { assignee: { object: 'assignee' }, project: { object: 'project' } },
                }),
            );
        });

        it('should convert select string to fields array', async () => {
            mockEngine.findOne.mockResolvedValue({ id: 't1', name: 'Test' });

            await protocol.getData({ object: 'task', id: 't1', select: 'name,status' });

            expect(mockEngine.findOne).toHaveBeenCalledWith(
                'task',
                expect.objectContaining({
                    where: { id: 't1' },
                    fields: ['name', 'status'],
                }),
            );
        });

        it('should pass both expand and fields together', async () => {
            mockEngine.findOne.mockResolvedValue({ id: 'oi_1' });

            await protocol.getData({
                object: 'order_item',
                id: 'oi_1',
                expand: 'order',
                select: ['name', 'total'],
            });

            expect(mockEngine.findOne).toHaveBeenCalledWith(
                'order_item',
                expect.objectContaining({
                    where: { id: 'oi_1' },
                    expand: { order: { object: 'order' } },
                    fields: ['name', 'total'],
                }),
            );
        });

        it('should work without expand or select', async () => {
            mockEngine.findOne.mockResolvedValue({ id: 't1' });

            await protocol.getData({ object: 'task', id: 't1' });

            expect(mockEngine.findOne).toHaveBeenCalledWith(
                'task',
                { where: { id: 't1' } },
            );
        });

        it('should return standard GetDataResponse shape', async () => {
            mockEngine.findOne.mockResolvedValue({ id: 'oi_1', name: 'Item 1' });

            const result = await protocol.getData({ object: 'order_item', id: 'oi_1' });

            expect(result).toEqual({
                object: 'order_item',
                id: 'oi_1',
                record: { id: 'oi_1', name: 'Item 1' },
            });
        });

        it('should throw when record not found', async () => {
            mockEngine.findOne.mockResolvedValue(null);

            await expect(
                protocol.getData({ object: 'task', id: 'missing_id' })
            ).rejects.toThrow('not found');
        });
    });

    // ═══════════════════════════════════════════════════════════════
    // Optimistic Concurrency Control — updateData / deleteData
    // ═══════════════════════════════════════════════════════════════
    describe('Optimistic Concurrency Control', () => {
        beforeEach(() => {
            // Both update and delete need `update` / `delete` on the
            // engine, plus `findOne` for the version probe.
            mockEngine.update = vi.fn().mockResolvedValue({ id: 'r1', updated_at: '2026-05-22T07:14:33.000Z' });
            mockEngine.delete = vi.fn().mockResolvedValue(true);
        });

        it('updateData proceeds when no expectedVersion is supplied (legacy callers)', async () => {
            await protocol.updateData({ object: 'task', id: 'r1', data: { name: 'New' } });
            // No version probe was issued
            expect(mockEngine.findOne).not.toHaveBeenCalled();
            expect(mockEngine.update).toHaveBeenCalledOnce();
        });

        it('updateData proceeds when expectedVersion matches current updated_at', async () => {
            mockEngine.findOne.mockResolvedValue({ id: 'r1', updated_at: '2026-05-22T07:14:00.000Z' });
            await protocol.updateData({
                object: 'task',
                id: 'r1',
                data: { name: 'New' },
                expectedVersion: '2026-05-22T07:14:00.000Z',
            });
            expect(mockEngine.findOne).toHaveBeenCalledOnce();
            expect(mockEngine.update).toHaveBeenCalledOnce();
        });

        it('updateData strips RFC-7232 quotes from the If-Match token', async () => {
            mockEngine.findOne.mockResolvedValue({ id: 'r1', updated_at: '2026-05-22T07:14:00.000Z' });
            await protocol.updateData({
                object: 'task',
                id: 'r1',
                data: { name: 'New' },
                expectedVersion: '"2026-05-22T07:14:00.000Z"',
            });
            expect(mockEngine.update).toHaveBeenCalledOnce();
        });

        it('updateData throws ConcurrentUpdateError when versions differ', async () => {
            mockEngine.findOne.mockResolvedValue({
                id: 'r1',
                updated_at: '2026-05-22T07:14:00.000Z',
                name: 'Server side',
            });
            await expect(
                protocol.updateData({
                    object: 'task',
                    id: 'r1',
                    data: { name: 'My change' },
                    expectedVersion: '2026-05-22T07:00:00.000Z',
                })
            ).rejects.toMatchObject({
                name: 'ConcurrentUpdateError',
                code: 'CONCURRENT_UPDATE',
                status: 409,
                currentVersion: '2026-05-22T07:14:00.000Z',
                currentRecord: expect.objectContaining({ id: 'r1', name: 'Server side' }),
            });
            // update was NOT invoked
            expect(mockEngine.update).not.toHaveBeenCalled();
        });

        it('updateData skips the check when the record has no updated_at column', async () => {
            mockEngine.findOne.mockResolvedValue({ id: 'r1', name: 'No timestamps' });
            await protocol.updateData({
                object: 'task',
                id: 'r1',
                data: { name: 'New' },
                expectedVersion: '2026-05-22T07:14:00.000Z',
            });
            expect(mockEngine.update).toHaveBeenCalledOnce();
        });

        it('updateData skips the check when expectedVersion is empty string', async () => {
            await protocol.updateData({
                object: 'task',
                id: 'r1',
                data: { name: 'New' },
                expectedVersion: '   ',
            });
            expect(mockEngine.findOne).not.toHaveBeenCalled();
            expect(mockEngine.update).toHaveBeenCalledOnce();
        });

        it('deleteData throws ConcurrentUpdateError on version mismatch', async () => {
            mockEngine.findOne.mockResolvedValue({
                id: 'r1',
                updated_at: '2026-05-22T07:14:00.000Z',
            });
            await expect(
                protocol.deleteData({
                    object: 'task',
                    id: 'r1',
                    expectedVersion: '2026-05-22T06:00:00.000Z',
                })
            ).rejects.toMatchObject({
                name: 'ConcurrentUpdateError',
                code: 'CONCURRENT_UPDATE',
            });
            expect(mockEngine.delete).not.toHaveBeenCalled();
        });

        it('deleteData proceeds when versions match', async () => {
            mockEngine.findOne.mockResolvedValue({
                id: 'r1',
                updated_at: '2026-05-22T07:14:00.000Z',
            });
            await protocol.deleteData({
                object: 'task',
                id: 'r1',
                expectedVersion: '2026-05-22T07:14:00.000Z',
            });
            expect(mockEngine.delete).toHaveBeenCalledOnce();
        });
    });
});
