const assert = require('node:assert/strict');

const sinon = require('sinon');
const configUtils = require('../../../../utils/config-utils');

const EmailAnalyticsService = require('../../../../../core/server/services/email-analytics/email-analytics-service');
const EventProcessingResult = require('../../../../../core/server/services/email-analytics/event-processing-result');

/**
 * Create a mock config object that reads from configUtils
 * This allows tests to use configUtils.set() while production code uses this.config.get()
 */
function createMockConfig() {
    return {
        get: key => configUtils.config.get(key)
    };
}

describe('EmailAnalyticsService', function () {
    let clock;

    beforeEach(function () {
        clock = sinon.useFakeTimers(new Date(2024, 0, 1));
    });

    afterEach(function () {
        clock.restore();
    });

    describe('getStatus', function () {
        it('returns status object', function () {
            // these are null because we're not running them before calling this
            const service = new EmailAnalyticsService({});
            const result = service.getStatus();
            assert.deepEqual(result, {
                latest: {
                    jobName: 'email-analytics-latest-others',
                    running: false
                },
                latestOpened: {
                    jobName: 'email-analytics-latest-opened',
                    running: false
                },
                missing: {
                    jobName: 'email-analytics-missing',
                    running: false
                },
                scheduled: {
                    jobName: 'email-analytics-scheduled',
                    running: false
                }
            });
        });
    });

    describe('getLastNonOpenedEventTimestamp', function () {
        it('returns the queried timestamp before the fallback', async function () {
            const service = new EmailAnalyticsService({
                config: createMockConfig(),
                queries: {
                    getLastEventTimestamp: sinon.stub().resolves(new Date(1))
                }
            });

            const result = await service.getLastNonOpenedEventTimestamp();
            assert.deepEqual(result, new Date(1));
        });

        it('returns the fallback if nothing is found', async function () {
            const service = new EmailAnalyticsService({
                config: createMockConfig(),
                queries: {
                    getLastEventTimestamp: sinon.stub().resolves(null)
                }
            });

            const result = await service.getLastNonOpenedEventTimestamp();
            assert.deepEqual(result, new Date(Date.now() - 30 * 60 * 1000)); // should be 30 mins prior
        });
    });

    describe('getLastSeenOpenedEventTimestamp', function () {
        it('returns the queried timestamp before the fallback', async function () {
            const service = new EmailAnalyticsService({
                config: createMockConfig(),
                queries: {
                    getLastEventTimestamp: sinon.stub().resolves(new Date(1))
                }
            });

            const result = await service.getLastOpenedEventTimestamp();
            assert.deepEqual(result, new Date(1));
        });

        it('returns the fallback if nothing is found', async function () {
            const service = new EmailAnalyticsService({
                config: createMockConfig(),
                queries: {
                    getLastEventTimestamp: sinon.stub().resolves(null)
                }
            });

            const result = await service.getLastOpenedEventTimestamp();
            assert.deepEqual(result, new Date(Date.now() - 30 * 60 * 1000)); // should be 30 mins prior
        });
    });

    describe('Fetching events', function () {
        afterEach(function () {
            sinon.restore();
        });
        describe('fetchLatestOpenedEvents', function () {
            it('fetches only opened events', async function () {
                const fetchLatestSpy = sinon.spy();
                const service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        getLastEventTimestamp: sinon.stub().resolves(),
                        setJobTimestamp: sinon.stub().resolves(),
                        setJobStatus: sinon.stub().resolves()
                    },
                    providers: [{
                        fetchLatest: fetchLatestSpy
                    }]
                });
                await service.fetchLatestOpenedEvents();
                sinon.assert.calledOnce(fetchLatestSpy);
                assert.deepEqual(fetchLatestSpy.getCall(0).args[1].events, ['opened']);
            });

            it('quits if the end is before the begin', async function () {
                const fetchLatestSpy = sinon.spy();
                const service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        getLastEventTimestamp: sinon.stub().resolves(new Date(Date.now() + 24 * 60 * 60 * 1000)), // 24 hours in the future
                        setJobTimestamp: sinon.stub().resolves(),
                        setJobStatus: sinon.stub().resolves()
                    },
                    providers: [{
                        fetchLatest: fetchLatestSpy
                    }]
                });
                await service.fetchLatestOpenedEvents();
                sinon.assert.notCalled(fetchLatestSpy);
            });
        });

        describe('fetchLatestNonOpenedEvents', function () {
            it('fetches only non-opened events', async function () {
                const fetchLatestSpy = sinon.spy();
                const service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        getLastEventTimestamp: sinon.stub().resolves(),
                        setJobTimestamp: sinon.stub().resolves(),
                        setJobStatus: sinon.stub().resolves()
                    },
                    providers: [{
                        fetchLatest: fetchLatestSpy
                    }]
                });
                await service.fetchLatestNonOpenedEvents();
                sinon.assert.calledOnce(fetchLatestSpy);
                assert.deepEqual(fetchLatestSpy.getCall(0).args[1].events, ['delivered', 'failed', 'unsubscribed', 'complained']);
            });

            it('quits if the end is before the begin', async function () {
                const fetchLatestSpy = sinon.spy();
                const service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        getLastEventTimestamp: sinon.stub().resolves(new Date(Date.now() + 24 * 60 * 60 * 1000)), // 24 hours in the future
                        setJobTimestamp: sinon.stub().resolves(),
                        setJobStatus: sinon.stub().resolves()
                    },
                    providers: [{
                        fetchLatest: fetchLatestSpy
                    }]
                });
                await service.fetchLatestNonOpenedEvents();
                sinon.assert.notCalled(fetchLatestSpy);
            });
        });
        describe('fetchScheduled', function () {
            let service;
            let processEventBatchStub;
            let aggregateStatsStub;
            let setJobTimestampStub;
            let setJobStatusStub;
            let setJobMetadataStub;

            beforeEach(function () {
                setJobTimestampStub = sinon.stub().resolves();
                setJobStatusStub = sinon.stub().resolves();
                setJobMetadataStub = sinon.stub().resolves();
                service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        setJobTimestamp: setJobTimestampStub,
                        setJobStatus: setJobStatusStub,
                        setJobMetadata: setJobMetadataStub
                    },
                    providers: [{
                        fetchLatest: (fn) => {
                            const events = [1,2,3,4,5,6,7,8,9,10];
                            fn(events);
                        }
                    }]
                });
                processEventBatchStub = sinon.stub(service, 'processEventBatch').resolves();
                aggregateStatsStub = sinon.stub(service, 'aggregateStats').resolves({emailAggregationTimeMs: 0, memberAggregationTimeMs: 0});
            });

            afterEach(function () {
                sinon.restore();
            });

            it('returns 0 when nothing is scheduled', async function () {
                const result = await service.fetchScheduled();
                assert.equal(result.eventCount, 0);
                sinon.assert.notCalled(processEventBatchStub);
                sinon.assert.notCalled(aggregateStatsStub);
            });

            it('returns 0 when fetch is canceled', async function () {
                await service.schedule({
                    begin: new Date(2023, 0, 1),
                    end: new Date(2023, 0, 2)
                });
                service.cancelScheduled();
                const result = await service.fetchScheduled();
                assert.equal(result.eventCount, 0);
                sinon.assert.notCalled(processEventBatchStub);
                sinon.assert.notCalled(aggregateStatsStub);
            });

            it('fetches events with correct parameters', async function () {
                await service.schedule({
                    begin: new Date(2023, 0, 1),
                    end: new Date(2023, 0, 2)
                });

                const result = await service.fetchScheduled({maxEvents: 100});

                assert.equal(result.eventCount, 10);
                sinon.assert.calledOnce(setJobStatusStub);
                sinon.assert.calledOnce(processEventBatchStub);
            });

            it('bails when end date is before begin date', async function () {
                await service.schedule({
                    begin: new Date(2023, 0, 2),
                    end: new Date(2023, 0, 1)
                });
                const result = await service.fetchScheduled({maxEvents: 100});
                assert.equal(result.eventCount, 0);
            });

            it('resets fetchScheduledData when no events are fetched', async function () {
                service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        setJobTimestamp: sinon.stub().resolves(),
                        setJobStatus: sinon.stub().resolves(),
                        setJobMetadata: sinon.stub().resolves()
                    },
                    providers: [{
                        fetchLatest: (fn) => {
                            fn([]);
                        }
                    }]
                });

                await service.schedule({
                    begin: new Date(2023, 0, 1),
                    end: new Date(2023, 0, 2)
                });
                const result = await service.fetchScheduled({maxEvents: 100});
                assert.equal(result.eventCount, 0);
            });
        });

        describe('schedule persistence', function () {
            let setJobMetadataStub;
            let service;

            beforeEach(function () {
                setJobMetadataStub = sinon.stub().resolves();
                service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        setJobMetadata: setJobMetadataStub,
                        setJobTimestamp: sinon.stub().resolves(),
                        setJobStatus: sinon.stub().resolves()
                    },
                    providers: [{
                        fetchLatest: (fn) => {
                            fn([]);
                        }
                    }]
                });
            });

            afterEach(function () {
                sinon.restore();
            });

            it('persists metadata when scheduling', async function () {
                const begin = new Date(2023, 0, 1);
                const end = new Date(2023, 0, 2);
                await service.schedule({begin, end});

                sinon.assert.calledOnce(setJobMetadataStub);
                sinon.assert.calledWith(setJobMetadataStub, 'email-analytics-scheduled', {
                    begin: begin.toISOString(),
                    end: end.toISOString()
                });
            });

            it('clears metadata when canceling a non-running schedule', async function () {
                await service.schedule({
                    begin: new Date(2023, 0, 1),
                    end: new Date(2023, 0, 2)
                });
                setJobMetadataStub.resetHistory();

                service.cancelScheduled();

                sinon.assert.calledOnce(setJobMetadataStub);
                sinon.assert.calledWith(setJobMetadataStub, 'email-analytics-scheduled', null);
            });

            it('clears metadata when fetchScheduled completes with no events', async function () {
                await service.schedule({
                    begin: new Date(2023, 0, 1),
                    end: new Date(2023, 0, 2)
                });
                setJobMetadataStub.resetHistory();

                await service.fetchScheduled({maxEvents: 100});

                sinon.assert.calledOnce(setJobMetadataStub);
                sinon.assert.calledWith(setJobMetadataStub, 'email-analytics-scheduled', null);
            });

            it('clears metadata when fetchScheduled finds end before begin', async function () {
                await service.schedule({
                    begin: new Date(2023, 0, 2),
                    end: new Date(2023, 0, 1)
                });
                setJobMetadataStub.resetHistory();

                await service.fetchScheduled({maxEvents: 100});

                sinon.assert.calledOnce(setJobMetadataStub);
                sinon.assert.calledWith(setJobMetadataStub, 'email-analytics-scheduled', null);
            });

            it('clears metadata when cancel is called on a non-running schedule', async function () {
                await service.schedule({
                    begin: new Date(2023, 0, 1),
                    end: new Date(2023, 0, 2)
                });
                setJobMetadataStub.resetHistory();

                service.cancelScheduled();

                // cancelScheduled on non-running calls #clearScheduledData which clears metadata
                sinon.assert.calledOnce(setJobMetadataStub);
                sinon.assert.calledWith(setJobMetadataStub, 'email-analytics-scheduled', null);

                // Subsequent fetchScheduled should be a no-op (nothing scheduled)
                const result = await service.fetchScheduled();
                assert.equal(result.eventCount, 0);
            });
        });

        describe('restoreScheduled', function () {
            afterEach(function () {
                sinon.restore();
            });

            it('restores schedule from persisted metadata', async function () {
                const begin = new Date(2023, 0, 1);
                const end = new Date(2023, 0, 8);
                const finishedAt = new Date(2023, 0, 3);

                const service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        getJobData: sinon.stub().resolves({
                            finished_at: finishedAt,
                            started_at: null,
                            metadata: JSON.stringify({
                                begin: begin.toISOString(),
                                end: end.toISOString()
                            })
                        }),
                        setJobMetadata: sinon.stub().resolves()
                    }
                });

                await service.restoreScheduled();

                const status = service.getStatus();
                assert.deepEqual(status.scheduled.schedule, {begin, end});
                assert.deepEqual(status.scheduled.lastEventTimestamp, finishedAt);
                assert.equal(status.scheduled.running, false);
            });

            it('does nothing when no job data exists', async function () {
                const service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        getJobData: sinon.stub().resolves(null),
                        setJobMetadata: sinon.stub().resolves()
                    }
                });

                await service.restoreScheduled();

                const status = service.getStatus();
                assert.equal(status.scheduled.schedule, undefined);
            });

            it('does nothing when metadata is null', async function () {
                const service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        getJobData: sinon.stub().resolves({
                            finished_at: null,
                            started_at: null,
                            metadata: null
                        }),
                        setJobMetadata: sinon.stub().resolves()
                    }
                });

                await service.restoreScheduled();

                const status = service.getStatus();
                assert.equal(status.scheduled.schedule, undefined);
            });

            it('restores without resume cursor when finished_at is null', async function () {
                const begin = new Date(2023, 0, 1);
                const end = new Date(2023, 0, 8);

                const service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        getJobData: sinon.stub().resolves({
                            finished_at: null,
                            started_at: null,
                            metadata: JSON.stringify({
                                begin: begin.toISOString(),
                                end: end.toISOString()
                            })
                        }),
                        setJobMetadata: sinon.stub().resolves()
                    }
                });

                await service.restoreScheduled();

                const status = service.getStatus();
                assert.deepEqual(status.scheduled.schedule, {begin, end});
                assert.equal(status.scheduled.lastEventTimestamp, undefined);
            });

            it('handles corrupt metadata gracefully', async function () {
                const service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        getJobData: sinon.stub().resolves({
                            finished_at: null,
                            started_at: null,
                            metadata: 'not-valid-json'
                        }),
                        setJobMetadata: sinon.stub().resolves()
                    }
                });

                await service.restoreScheduled();

                const status = service.getStatus();
                assert.equal(status.scheduled.schedule, undefined);
            });
        });

        describe('fetchMissing', function () {
            it('fetches missing events', async function () {
                const fetchLatestSpy = sinon.spy();
                const service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        setJobTimestamp: sinon.stub().resolves(),
                        setJobStatus: sinon.stub().resolves(),
                        getLastJobRunTimestamp: sinon.stub().resolves(new Date(Date.now() - 2.5 * 60 * 60 * 1000))
                    },
                    providers: [{
                        fetchLatest: fetchLatestSpy
                    }]
                });
                await service.fetchMissing();
                sinon.assert.calledOnce(fetchLatestSpy);
            });
        });
    });

    describe('processEventBatch', function () {
        // Run all processEventBatch tests with both batching modes
        [true, false].forEach((batchProcessing) => {
            const modeLabel = batchProcessing ? 'batching enabled' : 'batching disabled';

            describe(`with ${modeLabel}`, function () {
                beforeEach(function () {
                    configUtils.set('emailAnalytics:batchProcessing', batchProcessing);
                });

                afterEach(function () {
                    configUtils.restore();
                });

                describe('with functional processor', function () {
                    let eventProcessor;
                    beforeEach(function () {
                        eventProcessor = {};
                        eventProcessor.batchGetRecipients = sinon.stub().resolves(new Map());
                        eventProcessor.flushBatchedUpdates = sinon.stub().resolves();
                        eventProcessor.handleDelivered = sinon.stub().callsFake(({emailId}) => {
                            return {
                                emailId,
                                emailRecipientId: emailId,
                                memberId: 1
                            };
                        });
                        eventProcessor.handleOpened = sinon.stub().callsFake(({emailId}) => {
                            return {
                                emailId,
                                emailRecipientId: emailId,
                                memberId: 1
                            };
                        });
                        eventProcessor.handlePermanentFailed = sinon.stub().callsFake(({emailId}) => {
                            return {
                                emailId,
                                emailRecipientId: emailId,
                                memberId: 1
                            };
                        });
                        eventProcessor.handleTemporaryFailed = sinon.stub().callsFake(({emailId}) => {
                            return {
                                emailId,
                                emailRecipientId: emailId,
                                memberId: 1
                            };
                        });
                        eventProcessor.handleUnsubscribed = sinon.stub().callsFake(({emailId}) => {
                            return {
                                emailId,
                                emailRecipientId: emailId,
                                memberId: 1
                            };
                        });
                        eventProcessor.handleComplained = sinon.stub().callsFake(({emailId}) => {
                            return {
                                emailId,
                                emailRecipientId: emailId,
                                memberId: 1
                            };
                        });
                    });

                    it('uses passed-in event processor', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};
                        await service.processEventBatch([{
                            type: 'delivered',
                            emailId: 1,
                            timestamp: new Date(1)
                        }, {
                            type: 'delivered',
                            emailId: 2,
                            timestamp: new Date(2)
                        }, {
                            type: 'opened',
                            emailId: 1,
                            timestamp: new Date(3)
                        }], result, fetchData);

                        sinon.assert.calledTwice(eventProcessor.handleDelivered);
                        sinon.assert.calledOnce(eventProcessor.handleOpened);

                        assert.deepEqual(result, new EventProcessingResult({
                            delivered: 2,
                            opened: 1,
                            unprocessable: 0,
                            emailIds: [1, 2],
                            memberIds: [1]
                        }));

                        assert.deepEqual(fetchData, {
                            lastEventTimestamp: new Date(3)
                        });
                    });

                    it('handles opened', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'opened',
                            emailId: 1,
                            timestamp: new Date(1)
                        }], result, fetchData);

                        sinon.assert.calledOnce(eventProcessor.handleOpened);

                        assert.deepEqual(result, new EventProcessingResult({
                            delivered: 0,
                            opened: 1,
                            unprocessable: 0,
                            emailIds: [1],
                            memberIds: [1]
                        }));

                        assert.deepEqual(fetchData, {
                            lastEventTimestamp: new Date(1)
                        });
                    });

                    it('handles delivered', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'delivered',
                            emailId: 1,
                            timestamp: new Date(1)
                        }], result, fetchData);

                        sinon.assert.calledOnce(eventProcessor.handleDelivered);

                        assert.deepEqual(result, new EventProcessingResult({
                            delivered: 1,
                            opened: 0,
                            unprocessable: 0,
                            emailIds: [1],
                            memberIds: [1]
                        }));

                        assert.deepEqual(fetchData, {
                            lastEventTimestamp: new Date(1)
                        });
                    });

                    it('handles failed (permanent)', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'failed',
                            severity: 'permanent',
                            emailId: 1,
                            timestamp: new Date(1)
                        }], result, fetchData);

                        sinon.assert.calledOnce(eventProcessor.handlePermanentFailed);

                        assert.deepEqual(result, new EventProcessingResult({
                            permanentFailed: 1,
                            emailIds: [1],
                            memberIds: [1]
                        }));

                        assert.deepEqual(fetchData, {
                            lastEventTimestamp: new Date(1)
                        });
                    });

                    it('handles failed (temporary)', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'failed',
                            severity: 'temporary',
                            emailId: 1,
                            timestamp: new Date(1)
                        }], result, fetchData);

                        sinon.assert.calledOnce(eventProcessor.handleTemporaryFailed);

                        assert.deepEqual(result, new EventProcessingResult({
                            temporaryFailed: 1,
                            emailIds: [1],
                            memberIds: [1]
                        }));

                        assert.deepEqual(fetchData, {
                            lastEventTimestamp: new Date(1)
                        });
                    });

                    it('handles unsubscribed', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'unsubscribed',
                            emailId: 1,
                            timestamp: new Date(1)
                        }], result, fetchData);

                        sinon.assert.calledOnce(eventProcessor.handleUnsubscribed);
                        sinon.assert.notCalled(eventProcessor.handleDelivered);
                        sinon.assert.notCalled(eventProcessor.handleOpened);

                        assert.deepEqual(result, new EventProcessingResult({
                            unsubscribed: 1,
                            emailIds: [1],
                            memberIds: [1]
                        }));

                        assert.deepEqual(fetchData, {
                            lastEventTimestamp: new Date(1)
                        });
                    });

                    it('handles complained', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'complained',
                            emailId: 1,
                            timestamp: new Date(1)
                        }], result, fetchData);

                        sinon.assert.calledOnce(eventProcessor.handleComplained);
                        sinon.assert.notCalled(eventProcessor.handleDelivered);
                        sinon.assert.notCalled(eventProcessor.handleOpened);

                        assert.deepEqual(result, new EventProcessingResult({
                            complained: 1,
                            emailIds: [1],
                            memberIds: [1]
                        }));

                        assert.deepEqual(fetchData, {
                            lastEventTimestamp: new Date(1)
                        });
                    });

                    it(`doens't handle other event types`, async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'notstandard',
                            emailId: 1,
                            timestamp: new Date(1)
                        }], result, fetchData);

                        sinon.assert.notCalled(eventProcessor.handleDelivered);
                        sinon.assert.notCalled(eventProcessor.handleOpened);

                        assert.deepEqual(result, new EventProcessingResult({
                            unhandled: 1
                        }));

                        assert.deepEqual(fetchData, {
                            lastEventTimestamp: new Date(1)
                        });
                    });
                });

                describe('with null processor results', function () {
                    let eventProcessor;
                    beforeEach(function () {
                        eventProcessor = {};
                        eventProcessor.batchGetRecipients = sinon.stub().resolves(new Map());
                        eventProcessor.flushBatchedUpdates = sinon.stub().resolves();
                        eventProcessor.handleDelivered = sinon.stub().returns(null);
                        eventProcessor.handleOpened = sinon.stub().returns(null);
                        eventProcessor.handlePermanentFailed = sinon.stub().returns(null);
                        eventProcessor.handleTemporaryFailed = sinon.stub().returns(null);
                        eventProcessor.handleUnsubscribed = sinon.stub().returns(null);
                        eventProcessor.handleComplained = sinon.stub().returns(null);
                    });

                    it('delivered returns unprocessable', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'delivered',
                            emailId: 1,
                            timestamp: new Date(1)
                        }], result, fetchData);

                        assert.deepEqual(result, new EventProcessingResult({
                            unprocessable: 1
                        }));
                    });

                    it('opened returns unprocessable', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'opened',
                            emailId: 1,
                            timestamp: new Date(1)
                        }], result, fetchData);

                        assert.deepEqual(result, new EventProcessingResult({
                            unprocessable: 1
                        }));
                    });

                    it('failed (permanent) returns unprocessable', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'failed',
                            emailId: 1,
                            timestamp: new Date(1),
                            severity: 'permanent'
                        }], result, fetchData);

                        assert.deepEqual(result, new EventProcessingResult({
                            unprocessable: 1
                        }));
                    });

                    it('failed (temporary) returns unprocessable', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'failed',
                            emailId: 1,
                            timestamp: new Date(1),
                            severity: 'temporary'
                        }], result, fetchData);

                        assert.deepEqual(result, new EventProcessingResult({
                            unprocessable: 1
                        }));
                    });

                    it('unsubscribed returns unprocessable', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'unsubscribed',
                            emailId: 1,
                            timestamp: new Date(1)
                        }], result, fetchData);

                        assert.deepEqual(result, new EventProcessingResult({
                            unprocessable: 1
                        }));
                    });

                    it('complained returns unprocessable', async function () {
                        const service = new EmailAnalyticsService({
                            config: createMockConfig(),
                            eventProcessor
                        });

                        const result = new EventProcessingResult();
                        const fetchData = {};

                        await service.processEventBatch([{
                            type: 'complained',
                            emailId: 1,
                            timestamp: new Date(1)
                        }], result, fetchData);

                        assert.deepEqual(result, new EventProcessingResult({
                            unprocessable: 1
                        }));
                    });
                });

                it(`verifies batch methods called correctly in ${modeLabel} mode`, async function () {
                    const eventProcessor = {
                        batchGetRecipients: sinon.stub().resolves(new Map()),
                        flushBatchedUpdates: sinon.stub().resolves(),
                        handleDelivered: sinon.stub().resolves({emailId: 1, emailRecipientId: 1, memberId: 1})
                    };

                    const service = new EmailAnalyticsService({
                        config: createMockConfig(),
                        eventProcessor
                    });
                    const result = new EventProcessingResult();
                    const fetchData = {};

                    await service.processEventBatch([{
                        type: 'delivered',
                        emailId: 1,
                        timestamp: new Date(1)
                    }], result, fetchData);

                    if (batchProcessing) {
                        // In batched mode, should call batchGetRecipients and flushBatchedUpdates
                        sinon.assert.calledOnce(eventProcessor.batchGetRecipients);
                        sinon.assert.calledOnce(eventProcessor.flushBatchedUpdates);
                    } else {
                        // In sequential mode, should not call batch methods
                        sinon.assert.notCalled(eventProcessor.batchGetRecipients);
                        sinon.assert.notCalled(eventProcessor.flushBatchedUpdates);
                    }
                });
            });
        });
    });

    describe('aggregateStats', function () {
        describe('with batching enabled', function () {
            let service;

            beforeEach(function () {
                configUtils.set('emailAnalytics:batchProcessing', true);
                service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        aggregateEmailStats: sinon.spy(),
                        aggregateMemberStats: sinon.spy(),
                        aggregateMemberStatsBatch: sinon.spy()
                    }
                });
            });

            afterEach(function () {
                configUtils.restore();
            });

            it('calls batched query for member stats', async function () {
                await service.aggregateStats({
                    emailIds: ['e-1', 'e-2'],
                    memberIds: ['m-1', 'm-2']
                });

                sinon.assert.calledTwice(service.queries.aggregateEmailStats);
                sinon.assert.calledWith(service.queries.aggregateEmailStats, 'e-1');
                sinon.assert.calledWith(service.queries.aggregateEmailStats, 'e-2');

                // In batched mode, aggregateMemberStatsBatch should be called
                sinon.assert.calledOnce(service.queries.aggregateMemberStatsBatch);
                sinon.assert.calledWith(service.queries.aggregateMemberStatsBatch, ['m-1', 'm-2']);

                // Sequential method should not be called
                sinon.assert.notCalled(service.queries.aggregateMemberStats);
            });
        });

        describe('with batching disabled', function () {
            let service;

            beforeEach(function () {
                configUtils.set('emailAnalytics:batchProcessing', false);
                service = new EmailAnalyticsService({
                    config: createMockConfig(),
                    queries: {
                        aggregateEmailStats: sinon.spy(),
                        aggregateMemberStats: sinon.spy(),
                        aggregateMemberStatsBatch: sinon.spy()
                    }
                });
            });

            afterEach(function () {
                configUtils.restore();
            });

            it('calls sequential query for member stats', async function () {
                await service.aggregateStats({
                    emailIds: ['e-1', 'e-2'],
                    memberIds: ['m-1', 'm-2']
                });

                sinon.assert.calledTwice(service.queries.aggregateEmailStats);
                sinon.assert.calledWith(service.queries.aggregateEmailStats, 'e-1');
                sinon.assert.calledWith(service.queries.aggregateEmailStats, 'e-2');

                // In sequential mode, aggregateMemberStats should be called for each member
                sinon.assert.calledTwice(service.queries.aggregateMemberStats);
                sinon.assert.calledWith(service.queries.aggregateMemberStats, 'm-1');
                sinon.assert.calledWith(service.queries.aggregateMemberStats, 'm-2');

                // Batch method should not be called
                sinon.assert.notCalled(service.queries.aggregateMemberStatsBatch);
            });
        });
    });

    describe('aggregateEmailStats', function () {
        it('returns the query result', async function () {
            const service = new EmailAnalyticsService({
                config: createMockConfig(),
                queries: {
                    aggregateEmailStats: sinon.stub().resolves()
                }
            });

            await service.aggregateEmailStats('memberId');

            sinon.assert.calledOnce(service.queries.aggregateEmailStats);
            sinon.assert.calledWith(service.queries.aggregateEmailStats, 'memberId');
        });
    });

    describe('aggregateMemberStats', function () {
        it('returns the query result', async function () {
            const service = new EmailAnalyticsService({
                config: createMockConfig(),
                queries: {
                    aggregateMemberStats: sinon.stub().resolves()
                }
            });

            await service.aggregateMemberStats('memberId');

            sinon.assert.calledOnce(service.queries.aggregateMemberStats);
            sinon.assert.calledWith(service.queries.aggregateMemberStats, 'memberId');
        });
    });
});
