/**
 * Integration tests for OAuth2 callback with N8N_SKIP_AUTH_ON_OAUTH_CALLBACK=true
 *
 * IMPORTANT: Environment variable must be set before module imports
 * because skipAuthOnOAuthCallback is evaluated at module load time.
 */

// Set environment variable before any imports
process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'true';

import { testDb } from '@n8n/backend-test-utils';
import type { CredentialsEntity, User } from '@n8n/db';
import { Container } from '@n8n/di';
import { response as Response } from 'express';
import nock from 'nock';
import { parse as parseQs } from 'querystring';

import { CredentialsHelper } from '@/credentials-helper';
import { OauthService } from '@/oauth/oauth.service';
import { saveCredential } from '@test-integration/db/credentials';
import { createMember, createOwner } from '@test-integration/db/users';
import type { SuperAgentTest } from '@test-integration/types';
import { setupTestServer } from '@test-integration/utils';

describe('OAuth2 API with skipAuthOnOAuthCallback enabled', () => {
	const testServer = setupTestServer({ endpointGroups: ['oauth2'] });

	let owner: User;
	let anotherUser: User;
	let ownerAgent: SuperAgentTest;
	let credential: CredentialsEntity;
	const credentialData = {
		clientId: 'client_id',
		clientSecret: 'client_secret',
		authUrl: 'https://test.domain/oauth2/auth',
		accessTokenUrl: 'https://test.domain/oauth2/token',
		authQueryParameters: 'access_type=offline',
	};

	CredentialsHelper.prototype.applyDefaultsAndOverwrites = async (_, decryptedDataOriginal) =>
		decryptedDataOriginal;

	beforeAll(async () => {
		owner = await createOwner();
		anotherUser = await createMember();
		ownerAgent = testServer.authAgentFor(owner);
	});

	beforeEach(async () => {
		await testDb.truncate(['SharedCredentials', 'CredentialsEntity']);
		credential = await saveCredential(
			{
				name: 'Test',
				type: 'testOAuth2Api',
				data: credentialData,
			},
			{
				user: owner,
				role: 'credential:owner',
			},
		);
	});

	afterEach(() => {
		nock.cleanAll();
	});

	afterAll(async () => {
		// Clean up environment variable
		delete process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK;
	});

	describe('OAuth callback without authentication', () => {
		it('should handle OAuth callback without authentication when skipAuthOnOAuthCallback is enabled', async () => {
			const oauthService = Container.get(OauthService);
			const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
			const renderSpy = (Response.render = jest.fn(function () {
				this.end();
			}));

			// Step 1: Owner initiates OAuth flow (authenticated)
			await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);

			const [_, state] = csrfSpy.mock.results[0].value;

			// Step 2: Mock external OAuth provider response
			nock('https://test.domain')
				.post('/oauth2/token')
				.reply(200, { access_token: 'new_access_token' });

			// Step 3: Callback arrives WITHOUT authentication
			// This simulates the real-world scenario where skipAuth: true is configured
			// and the OAuth provider redirects back without going through auth middleware
			await testServer.authlessAgent
				.get('/oauth2-credential/callback')
				.query({ code: 'auth_code', state })
				.expect(200);

			// Verify success - should NOT render error page
			expect(renderSpy).toHaveBeenCalledWith('oauth-callback');
			expect(renderSpy).not.toHaveBeenCalledWith('oauth-error-callback', expect.anything());

			// Verify credential was updated with OAuth token
			const updatedCredential = await Container.get(CredentialsHelper).getCredentials(
				credential,
				credential.type,
			);
			expect(updatedCredential.getData()).toEqual({
				...credentialData,
				oauthTokenData: { access_token: 'new_access_token' },
			});
		});

		it('should allow callback completion by any user when skipAuthOnOAuthCallback is enabled', async () => {
			const oauthService = Container.get(OauthService);
			const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
			const renderSpy = (Response.render = jest.fn(function () {
				this.end();
			}));

			// Step 1: Owner initiates OAuth flow
			await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);

			const [_, state] = csrfSpy.mock.results[0].value;

			// Step 2: Mock external OAuth provider response
			nock('https://test.domain')
				.post('/oauth2/token')
				.reply(200, { access_token: 'different_user_token' });

			// Step 3: Different user completes the callback
			// When skipAuth is enabled, userId validation is skipped
			// This is intentional for scenarios where auth middleware cannot run
			await testServer
				.authAgentFor(anotherUser)
				.get('/oauth2-credential/callback')
				.query({ code: 'auth_code', state })
				.expect(200);

			// Should succeed without error
			expect(renderSpy).toHaveBeenCalledWith('oauth-callback');
			expect(renderSpy).not.toHaveBeenCalledWith('oauth-error-callback', expect.anything());

			// Verify credential was updated
			const updatedCredential = await Container.get(CredentialsHelper).getCredentials(
				credential,
				credential.type,
			);
			expect(updatedCredential.getData()).toEqual({
				...credentialData,
				oauthTokenData: { access_token: 'different_user_token' },
			});
		});
	});

	describe('OAuth flow initiation', () => {
		it('should return a valid auth URL when the auth flow is initiated', async () => {
			const response = await ownerAgent
				.get('/oauth2-credential/auth')
				.query({ id: credential.id })
				.expect(200);

			const authUrl = new URL(response.body.data);
			expect(authUrl.hostname).toBe('test.domain');
			expect(authUrl.pathname).toBe('/oauth2/auth');

			const queryParams = parseQs(authUrl.search.slice(1));
			expect(queryParams).toMatchObject({
				access_type: 'offline',
				client_id: 'client_id',
				redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback',
				response_type: 'code',
				scope: 'openid',
			});

			// Verify state is base64-encoded and contains expected structure
			expect(queryParams.state).toBeDefined();
			const decodedState = JSON.parse(
				Buffer.from(queryParams.state as string, 'base64').toString(),
			);
			expect(decodedState).toMatchObject({
				token: expect.any(String),
				createdAt: expect.any(Number),
				data: expect.any(String), // Encrypted CSRF data
			});
		});
	});

	describe('Error handling', () => {
		it('should still validate CSRF state even when skipAuthOnOAuthCallback is enabled', async () => {
			const renderSpy = (Response.render = jest.fn(function () {
				this.end();
			}));

			// Attempt callback with invalid state
			await testServer.authlessAgent
				.get('/oauth2-credential/callback')
				.query({ code: 'auth_code', state: 'invalid_state' })
				.expect(200);

			// Should render error due to invalid CSRF state
			expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', {
				error: expect.objectContaining({
					message: expect.any(String),
				}),
			});
		});

		it('should handle OAuth provider errors gracefully', async () => {
			const oauthService = Container.get(OauthService);
			const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
			const renderSpy = (Response.render = jest.fn(function () {
				this.end();
			}));

			// Initiate OAuth flow
			await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);

			const [_, state] = csrfSpy.mock.results[0].value;

			// Mock OAuth provider returning an error
			nock('https://test.domain').post('/oauth2/token').reply(400, {
				error: 'invalid_grant',
				error_description: 'Authorization code has expired',
			});

			// Callback should handle provider error
			await testServer.authlessAgent
				.get('/oauth2-credential/callback')
				.query({ code: 'expired_code', state })
				.expect(200);

			// Should render error callback
			expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', {
				error: expect.objectContaining({
					message: expect.any(String),
				}),
			});
		});
	});

	describe('Security validation', () => {
		it('should not skip CSRF token validation when skipAuthOnOAuthCallback is enabled', async () => {
			const oauthService = Container.get(OauthService);
			const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
			const renderSpy = (Response.render = jest.fn(function () {
				this.end();
			}));

			// Initiate OAuth flow to get a valid state
			await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);

			const [__, state] = csrfSpy.mock.results[0].value;

			// Tamper with the state (decrypt, modify, re-encrypt would be needed)
			// For this test, we'll use a completely different valid-looking but wrong state
			const tamperedState = state.replace(/[a-z]/, 'x');

			// Attempt callback with tampered state
			await testServer.authlessAgent
				.get('/oauth2-credential/callback')
				.query({ code: 'auth_code', state: tamperedState })
				.expect(200);

			// Should render error due to CSRF validation failure
			expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', {
				error: expect.objectContaining({
					message: expect.any(String),
				}),
			});
		});
	});
});
