/**
 * JavaScript tests for api.js CSRF handling and error behavior
 * Run with: cd tests/infrastructure_tests && npm test test_csrf_api.test.js
 *
 * Tests getCsrfToken, fetchWithErrorHandling, header merging, error handling,
 * timeouts, and the module.exports dual-guard pattern.
 */

// --- Global mocks (set BEFORE require) ---

global.document = {
    querySelector: jest.fn()
};

global.fetch = jest.fn();

global.SafeLogger = {
    warn: jest.fn(),
    error: jest.fn(),
    info: jest.fn(),
    debug: jest.fn()
};

global.window = {};

// Load urls.js first (dependency for URLS and URLBuilder globals)
const urlsModule = require('../../src/local_deep_research/web/static/js/config/urls.js');
global.URLS = global.window.URLS;
global.URLBuilder = global.window.URLBuilder;

// Load the module under test
const apiModule = require('../../src/local_deep_research/web/static/js/services/api.js');

// --- Helpers ---

/** Create a mock Response object */
function mockResponse({ ok = true, status = 200, statusText = 'OK', json = {}, jsonRejects = false } = {}) {
    return {
        ok,
        status,
        statusText,
        json: jsonRejects
            ? jest.fn().mockRejectedValue(new Error('JSON parse error'))
            : jest.fn().mockResolvedValue(json)
    };
}

/** Set up document.querySelector to return a meta tag with the given token */
function setMetaToken(token) {
    global.document.querySelector.mockReturnValue({
        getAttribute: jest.fn().mockReturnValue(token)
    });
}

/** Set up document.querySelector to return null (no meta tag) */
function clearMetaToken() {
    global.document.querySelector.mockReturnValue(null);
}

// ============================================================
// 1. Module exports shape
// ============================================================
describe('Module exports shape', () => {
    test('require() returns object with expected function names', () => {
        const expectedExports = [
            'getCsrfToken', 'fetchWithErrorHandling', 'getApiUrl', 'postJSON',
            'startResearch', 'getResearchStatus', 'getResearchDetails',
            'getResearchLogs', 'getResearchHistory', 'getReport',
            'getMarkdownExport', 'terminateResearch', 'deleteResearch',
            'clearResearchHistory', 'openFileLocation', 'saveRawConfig',
            'saveMainConfig', 'saveSearchEnginesConfig', 'saveCollectionsConfig',
            'saveApiKeysConfig', 'saveLlmConfig'
        ];

        expectedExports.forEach(name => {
            expect(apiModule[name]).toBeDefined();
            expect(typeof apiModule[name]).toBe('function');
        });
    });

    test('getCsrfToken and fetchWithErrorHandling are functions on the export', () => {
        expect(typeof apiModule.getCsrfToken).toBe('function');
        expect(typeof apiModule.fetchWithErrorHandling).toBe('function');
    });

    test('window.api is also populated (browser path)', () => {
        expect(global.window.api).toBeDefined();
        expect(typeof global.window.api.startResearch).toBe('function');
        expect(typeof global.window.api.postJSON).toBe('function');
    });

    test('window.api.getCsrfToken exists (enables PR #2453 delegation pattern)', () => {
        expect(typeof global.window.api.getCsrfToken).toBe('function');
    });
});

// ============================================================
// 2. getCsrfToken - core behavior
// ============================================================
describe('getCsrfToken', () => {
    const { getCsrfToken } = apiModule;

    beforeEach(() => {
        jest.clearAllMocks();
    });

    test('returns token string when meta tag is present', () => {
        setMetaToken('test-csrf-token-123');
        expect(getCsrfToken()).toBe('test-csrf-token-123');
    });

    test('returns empty string when querySelector returns null', () => {
        clearMetaToken();
        expect(getCsrfToken()).toBe('');
    });

    test('returns null when getAttribute("content") returns null', () => {
        global.document.querySelector.mockReturnValue({
            getAttribute: jest.fn().mockReturnValue(null)
        });
        // Documenting latent behavior: null is falsy so downstream is safe
        expect(getCsrfToken()).toBeNull();
    });

    test('queries the correct selector meta[name="csrf-token"]', () => {
        clearMetaToken();
        getCsrfToken();
        expect(global.document.querySelector).toHaveBeenCalledWith('meta[name="csrf-token"]');
    });
});

// ============================================================
// 3. fetchWithErrorHandling - CSRF injection
// ============================================================
describe('fetchWithErrorHandling - CSRF injection', () => {
    const { fetchWithErrorHandling } = apiModule;

    beforeEach(() => {
        jest.clearAllMocks();
        global.fetch.mockResolvedValue(mockResponse({ json: { ok: true } }));
    });

    test('injects X-CSRFToken for POST (via postJSON)', async () => {
        setMetaToken('post-token');
        await apiModule.postJSON('/api/test', { data: 1 });

        const fetchCall = global.fetch.mock.calls[0];
        expect(fetchCall[1].headers['X-CSRFToken']).toBe('post-token');
        expect(fetchCall[1].method).toBe('POST');
    });

    test('injects X-CSRFToken for DELETE (via deleteResearch)', async () => {
        setMetaToken('delete-token');
        await apiModule.deleteResearch(42);

        const fetchCall = global.fetch.mock.calls[0];
        expect(fetchCall[1].headers['X-CSRFToken']).toBe('delete-token');
        expect(fetchCall[1].method).toBe('DELETE');
    });

    test('does NOT inject X-CSRFToken when meta tag absent (empty string is falsy)', async () => {
        clearMetaToken();
        await fetchWithErrorHandling('/api/test');

        const fetchCall = global.fetch.mock.calls[0];
        expect(fetchCall[1].headers['X-CSRFToken']).toBeUndefined();
    });

    test('always sets Content-Type: application/json', async () => {
        clearMetaToken();
        await fetchWithErrorHandling('/api/test');

        const fetchCall = global.fetch.mock.calls[0];
        expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
    });
});

// ============================================================
// 4. Header merge order
// ============================================================
describe('Header merge order', () => {
    const { fetchWithErrorHandling } = apiModule;

    beforeEach(() => {
        jest.clearAllMocks();
        global.fetch.mockResolvedValue(mockResponse({ json: { ok: true } }));
    });

    test('default Content-Type: application/json is set', async () => {
        clearMetaToken();
        await fetchWithErrorHandling('/api/test');

        const headers = global.fetch.mock.calls[0][1].headers;
        expect(headers['Content-Type']).toBe('application/json');
    });

    test('caller Content-Type overrides default', async () => {
        clearMetaToken();
        await fetchWithErrorHandling('/api/test', {
            headers: { 'Content-Type': 'text/plain' }
        });

        const headers = global.fetch.mock.calls[0][1].headers;
        expect(headers['Content-Type']).toBe('text/plain');
    });

    test('caller X-CSRFToken overrides auto-injected one', async () => {
        setMetaToken('auto-token');
        await fetchWithErrorHandling('/api/test', {
            headers: { 'X-CSRFToken': 'manual-token' }
        });

        const headers = global.fetch.mock.calls[0][1].headers;
        expect(headers['X-CSRFToken']).toBe('manual-token');
    });

    test('caller headers coexist with defaults (no key loss)', async () => {
        setMetaToken('my-token');
        await fetchWithErrorHandling('/api/test', {
            headers: { 'X-Custom': 'custom-value' }
        });

        const headers = global.fetch.mock.calls[0][1].headers;
        expect(headers['Content-Type']).toBe('application/json');
        expect(headers['X-CSRFToken']).toBe('my-token');
        expect(headers['X-Custom']).toBe('custom-value');
    });

    test('null/undefined caller headers handled via || {} guard', async () => {
        setMetaToken('safe-token');

        // Explicitly pass headers: undefined
        await fetchWithErrorHandling('/api/test', { headers: undefined });
        const headers1 = global.fetch.mock.calls[0][1].headers;
        expect(headers1['Content-Type']).toBe('application/json');
        expect(headers1['X-CSRFToken']).toBe('safe-token');

        // Explicitly pass headers: null
        await fetchWithErrorHandling('/api/test', { headers: null });
        const headers2 = global.fetch.mock.calls[1][1].headers;
        expect(headers2['Content-Type']).toBe('application/json');
        expect(headers2['X-CSRFToken']).toBe('safe-token');
    });
});

// ============================================================
// 5. Token rotation / idempotency
// ============================================================
describe('Token rotation / idempotency', () => {
    const { fetchWithErrorHandling } = apiModule;

    beforeEach(() => {
        jest.clearAllMocks();
        global.fetch.mockResolvedValue(mockResponse({ json: { ok: true } }));
    });

    test('fresh token read on each call (no caching)', async () => {
        setMetaToken('token-v1');
        await fetchWithErrorHandling('/api/test');
        expect(global.fetch.mock.calls[0][1].headers['X-CSRFToken']).toBe('token-v1');

        setMetaToken('token-v2');
        await fetchWithErrorHandling('/api/test');
        expect(global.fetch.mock.calls[1][1].headers['X-CSRFToken']).toBe('token-v2');
    });

    test('token change between calls is reflected', async () => {
        setMetaToken('first');
        await fetchWithErrorHandling('/api/a');

        setMetaToken('second');
        await fetchWithErrorHandling('/api/b');

        const firstHeaders = global.fetch.mock.calls[0][1].headers;
        const secondHeaders = global.fetch.mock.calls[1][1].headers;

        expect(firstHeaders['X-CSRFToken']).toBe('first');
        expect(secondHeaders['X-CSRFToken']).toBe('second');
    });

    test('document.querySelector called exactly once per fetchWithErrorHandling invocation', async () => {
        setMetaToken('once-token');
        await fetchWithErrorHandling('/api/test');
        // One call from fetchWithErrorHandling's getCsrfToken()
        expect(global.document.querySelector).toHaveBeenCalledTimes(1);
    });
});

// ============================================================
// 6. Error handling - non-ok responses
// ============================================================
describe('Error handling - non-ok responses', () => {
    const { fetchWithErrorHandling } = apiModule;

    beforeEach(() => {
        jest.clearAllMocks();
        clearMetaToken();
    });

    test('response.ok === false with .message in JSON body throws server message', async () => {
        global.fetch.mockResolvedValue(mockResponse({
            ok: false,
            status: 422,
            statusText: 'Unprocessable Entity',
            json: { message: 'Validation failed: bad input' }
        }));

        await expect(fetchWithErrorHandling('/api/test'))
            .rejects.toThrow('Validation failed: bad input');
    });

    test('response.ok === false without .message throws API Error template', async () => {
        global.fetch.mockResolvedValue(mockResponse({
            ok: false,
            status: 500,
            statusText: 'Internal Server Error',
            json: {}
        }));

        await expect(fetchWithErrorHandling('/api/test'))
            .rejects.toThrow('API Error: 500 Internal Server Error');
    });

    test('response.ok === false and response.json() rejects falls to status template', async () => {
        global.fetch.mockResolvedValue(mockResponse({
            ok: false,
            status: 503,
            statusText: 'Service Unavailable',
            jsonRejects: true
        }));

        await expect(fetchWithErrorHandling('/api/test'))
            .rejects.toThrow('API Error: 503 Service Unavailable');
    });

    test('SafeLogger.error called on all non-abort errors', async () => {
        global.fetch.mockResolvedValue(mockResponse({
            ok: false,
            status: 400,
            statusText: 'Bad Request',
            json: { message: 'oops' }
        }));

        await expect(fetchWithErrorHandling('/api/test')).rejects.toThrow('oops');
        expect(global.SafeLogger.error).toHaveBeenCalledWith('API Error:', expect.any(Error));
    });
});

// ============================================================
// 7. Error handling - network and timeout
// ============================================================
describe('Error handling - network and timeout', () => {
    const { fetchWithErrorHandling } = apiModule;

    beforeEach(() => {
        jest.clearAllMocks();
        clearMetaToken();
    });

    test('network error: CSRF header still set before rejection', async () => {
        setMetaToken('net-error-token');
        global.fetch.mockRejectedValue(new TypeError('Failed to fetch'));

        await expect(fetchWithErrorHandling('/api/test')).rejects.toThrow('Failed to fetch');
        expect(global.SafeLogger.error).toHaveBeenCalled();
    });

    test('AbortError maps to "Request timed out"', async () => {
        const abortError = new Error('The operation was aborted');
        abortError.name = 'AbortError';
        global.fetch.mockRejectedValue(abortError);

        await expect(fetchWithErrorHandling('/api/test'))
            .rejects.toThrow('Request timed out');
    });

    test('SafeLogger.error NOT called on AbortError', async () => {
        const abortError = new Error('The operation was aborted');
        abortError.name = 'AbortError';
        global.fetch.mockRejectedValue(abortError);

        await expect(fetchWithErrorHandling('/api/test')).rejects.toThrow();
        expect(global.SafeLogger.error).not.toHaveBeenCalled();
    });

    test('non-abort errors re-thrown unchanged', async () => {
        const customError = new Error('Custom network failure');
        global.fetch.mockRejectedValue(customError);

        await expect(fetchWithErrorHandling('/api/test'))
            .rejects.toThrow('Custom network failure');
    });

    test('happy-path response.json() rejects is caught, logged, and re-thrown', async () => {
        const resp = mockResponse({ json: {} });
        resp.ok = true;
        resp.json = jest.fn().mockRejectedValue(new SyntaxError('Unexpected end of JSON'));
        global.fetch.mockResolvedValue(resp);

        await expect(fetchWithErrorHandling('/api/test'))
            .rejects.toThrow('Unexpected end of JSON');
        expect(global.SafeLogger.error).toHaveBeenCalledWith('API Error:', expect.any(SyntaxError));
    });
});

// ============================================================
// 8. finally block - clearTimeout
// ============================================================
describe('finally block - clearTimeout', () => {
    const { fetchWithErrorHandling } = apiModule;
    let clearTimeoutSpy;

    beforeEach(() => {
        jest.clearAllMocks();
        clearMetaToken();
        clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
    });

    afterEach(() => {
        clearTimeoutSpy.mockRestore();
    });

    test('clearTimeout called on success path', async () => {
        global.fetch.mockResolvedValue(mockResponse({ json: { data: 1 } }));
        await fetchWithErrorHandling('/api/test');
        expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
    });

    test('clearTimeout called on error path', async () => {
        global.fetch.mockResolvedValue(mockResponse({
            ok: false,
            status: 500,
            statusText: 'Error',
            json: { message: 'fail' }
        }));

        await expect(fetchWithErrorHandling('/api/test')).rejects.toThrow();
        expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
    });

    test('clearTimeout called on timeout/abort path', async () => {
        const abortError = new Error('aborted');
        abortError.name = 'AbortError';
        global.fetch.mockRejectedValue(abortError);

        await expect(fetchWithErrorHandling('/api/test')).rejects.toThrow();
        expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
    });
});

// ============================================================
// 9. deleteResearch - regression guard
// ============================================================
describe('deleteResearch - regression guard', () => {
    beforeEach(() => {
        jest.clearAllMocks();
        global.fetch.mockResolvedValue(mockResponse({ json: { deleted: true } }));
    });

    test('sends DELETE method', async () => {
        setMetaToken('del-token');
        await apiModule.deleteResearch(99);

        const fetchCall = global.fetch.mock.calls[0];
        expect(fetchCall[1].method).toBe('DELETE');
    });

    test('CSRF token present in outgoing headers', async () => {
        setMetaToken('csrf-for-delete');
        await apiModule.deleteResearch(99);

        const fetchCall = global.fetch.mock.calls[0];
        expect(fetchCall[1].headers['X-CSRFToken']).toBe('csrf-for-delete');
    });

    test('correct URL constructed via URLBuilder', async () => {
        setMetaToken('url-token');
        await apiModule.deleteResearch(42);

        const fetchCall = global.fetch.mock.calls[0];
        expect(fetchCall[0]).toBe('/api/delete/42');
    });

    test('returns parsed JSON response', async () => {
        setMetaToken('ret-token');
        const result = await apiModule.deleteResearch(7);
        expect(result).toEqual({ deleted: true });
    });
});
