// helpers/apiRequest.ts
import {
	IExecuteFunctions,
	IHttpRequestOptions,
	NodeApiError,
	NodeOperationError,
	IDataObject,
	IHttpRequestMethods,
	ICredentialsHelper,
	ICredentialDataDecryptedObject,
} from 'n8n-workflow';
import { logger } from './logger';

// Token cache with exponential backoff for rate limiting
interface TokenCacheEntry {
	accessToken: string;
	refreshToken: string;
	expiresAt: number;
	retryAfter?: number;
	retryTimestamp?: number;
}

const tokenCache: Record<string, TokenCacheEntry> = {};
let sessionToken: string;
let csrfToken: string;

/**
 * Extract a token from cookies
 */
function extractTokenFromCookies(cookies: string[] | undefined, tokenName: string): string {
	if (!cookies || cookies.length === 0) {
		throw new Error(`No cookies found when extracting ${tokenName}`);
	}

	for (const cookie of cookies) {
		const match = new RegExp(`${tokenName}=([^;]+)`).exec(cookie);
		if (match) {
			return match[1];
		}
	}

	throw new Error(`${tokenName} not found in cookies`);
}

/**
 * Check if in rate limit backoff period
 */
function isRateLimited(cacheKey: string): { isLimited: boolean; waitTime: number } {
	if (tokenCache[cacheKey]?.retryAfter && tokenCache[cacheKey]?.retryTimestamp) {
		const now = Date.now();
		const retryTime =
			tokenCache[cacheKey].retryTimestamp! + tokenCache[cacheKey].retryAfter! * 1000;
		if (now < retryTime) {
			return {
				isLimited: true,
				waitTime: Math.ceil((retryTime - now) / 1000),
			};
		}
	}
	return { isLimited: false, waitTime: 0 };
}

/**
 * Handle rate limiting response
 */
function handleRateLimiting(cacheKey: string, response: any): void {
	// Parse retry time from response
	let retryAfter = 60; // Default 60 seconds
	try {
		// Check if retryAfter was directly provided
		if (response.retryAfter && typeof response.retryAfter === 'number') {
			retryAfter = response.retryAfter;
		}
		// Check for retry-after header
		else if (response.headers && response.headers['retry-after']) {
			retryAfter = parseInt(response.headers['retry-after'], 10);
		}
		// Try to parse from error message
		else {
			const message = response?.message || response?.error_description || '';
			const match = message.match(/retry after (\d+) seconds/i) || message.match(/(\d+) seconds/);
			if (match && match[1]) {
				retryAfter = parseInt(match[1], 10);
			}
		}
	} catch (e) {
		logger.error('auth:ratelimit', `Failed to parse retry time: ${e.message}`);
	}

	// Store rate limit info in cache
	if (tokenCache[cacheKey]) {
		tokenCache[cacheKey].retryAfter = retryAfter;
		tokenCache[cacheKey].retryTimestamp = Date.now();
	} else {
		tokenCache[cacheKey] = {
			accessToken: '',
			refreshToken: '',
			expiresAt: 0,
			retryAfter,
			retryTimestamp: Date.now(),
		};
	}

	logger.warn('auth:ratelimit', `Rate limited. Will retry after ${retryAfter} seconds`);
}

/**
 * Get token data from n8n storage
 */
function getTokenFromCredentials(credentials: ICredentialDataDecryptedObject): {
	accessToken: string;
	refreshToken: string;
	expiresAt: number;
} | null {
	logger.debug('auth:storage', 'Checking for token in n8n credential storage');

	try {
		if (credentials.oauthTokenData) {
			const tokenData = credentials.oauthTokenData as IDataObject;

			logger.debug('auth:storage', 'Token data found in credentials');

			if (tokenData.access_token && tokenData.expires_at) {
				logger.debug('auth:storage', 'Found access token and expiration');
				return {
					accessToken: tokenData.access_token as string,
					refreshToken: (tokenData.refresh_token as string) || '',
					expiresAt: tokenData.expires_at as number,
				};
			}
		}

		logger.debug('auth:storage', 'No valid token data found in credential storage');
		return null;
	} catch (error) {
		logger.error('auth:storage', `Error retrieving token from credentials: ${error.message}`);
		return null;
	}
}

/**
 * Store OAuth token data in n8n credentials or workflow storage for persistence
 */
async function storeTokenData(
	this: IExecuteFunctions,
	tokenData: {
		access_token: string;
		refresh_token: string;
		expires_in: number;
	},
	credentialId: string,
): Promise<void> {
	try {
		// Calculate expiration timestamp
		const expiresAt = Date.now() + tokenData.expires_in * 1000;

		// Format token data for storage
		const oauthTokenData = {
			access_token: tokenData.access_token,
			refresh_token: tokenData.refresh_token,
			expires_in: tokenData.expires_in,
			expires_at: expiresAt,
			token_type: 'Bearer',
		};

		// Try different methods to store the token based on available n8n APIs
		let stored = false;

		// Method 1: Try using nodeHelpers if available (newer n8n versions)
		try {
			if (
				this.helpers.nodeHelpers &&
				typeof this.helpers.nodeHelpers.updateCredentials === 'function'
			) {
				await this.helpers.nodeHelpers.updateCredentials(credentialId, {
					oauthTokenData,
				});
				logger.debug('auth:oauth2', 'Token stored using nodeHelpers.updateCredentials');
				stored = true;
			}
		} catch (error) {
			logger.debug('auth:oauth2', `nodeHelpers.updateCredentials failed: ${error.message}`);
		}

		// Method 2: Try direct updateCredentials if available (older n8n versions)
		if (!stored && typeof this.helpers.updateCredentials === 'function') {
			try {
				await this.helpers.updateCredentials(credentialId, {
					oauthTokenData,
				});
				logger.debug('auth:oauth2', 'Token stored using helpers.updateCredentials');
				stored = true;
			} catch (error) {
				logger.debug('auth:oauth2', `helpers.updateCredentials failed: ${error.message}`);
			}
		}

		// Method 3: Fall back to workflow static data if credential update is not possible
		if (!stored && typeof this.getWorkflowStaticData === 'function') {
			const workflowStaticData = this.getWorkflowStaticData('node');
			workflowStaticData.oauthTokenData = oauthTokenData;
			logger.debug('auth:oauth2', 'Token stored in workflow static data (fallback method)');
			stored = true;
		}

		if (!stored) {
			logger.warn(
				'auth:oauth2',
				'Could not persist token data - no suitable storage method available',
			);
		}
	} catch (error) {
		logger.error('auth:oauth2', `Failed to persist token data: ${error.message}`);
		// We continue without throwing as not persisting is better than failing completely
	}
}

/**
 * Check if a token is expired
 */
function isTokenExpired(expiresAt: number): boolean {
	// Consider token expired 60 seconds before actual expiration
	return Date.now() > expiresAt - 60000;
}

/**
 * Get an access token using the OAuth2 flow
 */
export async function getAccessToken(
	this: IExecuteFunctions,
	credentials: ICredentialDataDecryptedObject,
): Promise<string> {
	logger.debug('auth:oauth2', 'Getting access token...');

	const cacheKey = `${credentials.baseUrl}_${credentials.clientId}`;
	const credentialId = credentials.$credentialId as string;

	// Check rate limiting first
	const rateLimitStatus = isRateLimited(cacheKey);
	if (rateLimitStatus.isLimited) {
		throw new Error(
			`API rate limited. Please try again after ${rateLimitStatus.waitTime} seconds.`,
		);
	}

	// First try workflow static data as a persistent token source
	if (typeof this.getWorkflowStaticData === 'function') {
		const workflowStaticData = this.getWorkflowStaticData('node');
		if (workflowStaticData.oauthTokenData) {
			const tokenData = workflowStaticData.oauthTokenData as IDataObject;
			if (
				tokenData.access_token &&
				tokenData.expires_at &&
				Date.now() < (tokenData.expires_at as number)
			) {
				logger.debug('auth:oauth2', 'Using valid token from workflow static data');
				return tokenData.access_token as string;
			} else {
				logger.debug('auth:oauth2', 'Token in workflow static data is expired or invalid');
			}
		}
	}

	// Second, try to get token from n8n credential storage
	const storedToken = getTokenFromCredentials(credentials);
	logger.debug(
		'auth:oauth2',
		storedToken ? 'Found token in credential storage' : 'No token in credential storage',
	);

	if (storedToken && !isTokenExpired(storedToken.expiresAt)) {
		logger.debug('auth:oauth2', 'Using valid access token from n8n credential storage');

		// Update runtime cache for faster access next time
		tokenCache[cacheKey] = {
			accessToken: storedToken.accessToken,
			refreshToken: storedToken.refreshToken,
			expiresAt: storedToken.expiresAt,
		};

		return storedToken.accessToken;
	}

	// Third, check in-memory cache
	if (tokenCache[cacheKey] && !isTokenExpired(tokenCache[cacheKey].expiresAt)) {
		logger.debug('auth:oauth2', 'Using valid token from in-memory cache');
		return tokenCache[cacheKey].accessToken;
	}

	// Fourth, try to refresh the token if we have a refresh token
	const refreshToken = storedToken?.refreshToken || tokenCache[cacheKey]?.refreshToken;

	if (refreshToken) {
		logger.debug('auth:oauth2', 'Attempting to refresh token');

		try {
			const refreshResponse = await this.helpers.request({
				method: 'POST',
				uri: `${credentials.baseUrl}/oauth2/token`,
				qs: {
					client_id: credentials.clientId,
					client_secret: credentials.clientSecret,
					grant_type: 'refresh_token',
					refresh_token: refreshToken,
				},
				json: true,
			});

			logger.debug('auth:oauth2', 'Token refresh successful');

			// Format and store the new token
			const expiresAt = Date.now() + refreshResponse.expires_in * 1000;

			// Update the runtime cache
			tokenCache[cacheKey] = {
				accessToken: refreshResponse.access_token,
				refreshToken: refreshResponse.refresh_token,
				expiresAt,
			};

			// Persist tokens
			await storeTokenData.call(
				this,
				{
					access_token: refreshResponse.access_token,
					refresh_token: refreshResponse.refresh_token,
					expires_in: refreshResponse.expires_in,
				},
				credentialId,
			);

			logger.debug('auth:oauth2', 'New token stored after refresh');
			return refreshResponse.access_token;
		} catch (error) {
			logger.error('auth:oauth2', `Token refresh failed: ${error.message}`);
			logger.debug('auth:oauth2', 'Proceeding to full authentication flow');

			// Check for rate limiting in refresh error
			if (error.statusCode === 429) {
				handleRateLimiting(cacheKey, error.error);
				throw new Error(`API rate limited during token refresh. Please try again later.`);
			}
		}
	}

	// Full OAuth flow
	logger.debug('auth:oauth2', 'Starting full OAuth flow...');
	try {
		// Step 1: Login to obtain session and CSRF tokens.
		const loginUrl = `${credentials.baseUrl}/oauth2/authorize/central/api/login?client_id=${credentials.clientId}`;
		logger.debug('auth:oauth2', `Calling login endpoint: ${loginUrl}`);

		const loginResponse = await this.helpers.request({
			method: 'POST',
			uri: loginUrl,
			headers: {
				'Content-Type': 'application/json',
				Accept: 'application/json',
			},
			body: {
				username: credentials.username,
				password: credentials.password,
			},
			json: true,
			resolveWithFullResponse: true,
		});
		logger.debug('auth:oauth2', 'Login response received');

		// Extract tokens from response cookies.
		try {
			sessionToken = extractTokenFromCookies(loginResponse.headers['set-cookie'], 'session');
			csrfToken = extractTokenFromCookies(loginResponse.headers['set-cookie'], 'csrftoken');
			logger.debug('auth:oauth2', 'Session and CSRF tokens extracted successfully');
		} catch (error) {
			logger.error('auth:oauth2', `Failed to extract tokens: ${error.message}`);
			throw new Error(`Authentication failed: ${error.message}`);
		}

		// Step 2: Generate authorization code.
		const authCodeUrl = `${credentials.baseUrl}/oauth2/authorize/central/api?client_id=${credentials.clientId}&response_type=code&scope=all`;
		logger.debug('auth:oauth2', `Calling authorization code endpoint: ${authCodeUrl}`);

		const authCodeResponse = await this.helpers.request({
			method: 'POST',
			uri: authCodeUrl,
			headers: {
				'Content-Type': 'application/json',
				Cookie: `session=${sessionToken}`,
				'X-CSRF-Token': csrfToken,
			},
			body: {
				customer_id: credentials.customerId,
			},
			json: true,
		});

		if (!authCodeResponse || !authCodeResponse.auth_code) {
			logger.error('auth:oauth2', 'No authorization code in response', authCodeResponse);
			throw new Error('Failed to obtain authorization code');
		}

		const authorizationCode = authCodeResponse.auth_code;
		logger.debug('auth:oauth2', 'Authorization code obtained successfully');

		// Step 3: Exchange the authorization code for an access token.
		const tokenUrl = `${credentials.baseUrl}/oauth2/token`;
		logger.debug('auth:oauth2', `Exchanging authorization code for access token at: ${tokenUrl}`);

		const tokenResponse = await this.helpers.request({
			method: 'POST',
			uri: tokenUrl,
			headers: {
				'Content-Type': 'application/json',
			},
			body: {
				client_id: credentials.clientId,
				client_secret: credentials.clientSecret,
				grant_type: 'authorization_code',
				code: authorizationCode,
			},
			json: true,
		});

		if (!tokenResponse || !tokenResponse.access_token) {
			logger.error('auth:oauth2', 'No access token in response', tokenResponse);
			throw new Error('Failed to obtain access token');
		}

		// Calculate expiration
		const expiresAt = Date.now() + tokenResponse.expires_in * 1000;

		// Store in runtime cache
		tokenCache[cacheKey] = {
			accessToken: tokenResponse.access_token,
			refreshToken: tokenResponse.refresh_token,
			expiresAt,
		};

		// Store persistently
		await storeTokenData.call(
			this,
			{
				access_token: tokenResponse.access_token,
				refresh_token: tokenResponse.refresh_token,
				expires_in: tokenResponse.expires_in,
			},
			credentialId,
		);

		logger.debug('auth:oauth2', 'Full OAuth flow completed successfully');
		return tokenResponse.access_token;
	} catch (error) {
		logger.error('auth:oauth2', `OAuth authentication failed: ${error.message}`);

		// Enhanced rate limiting detection
		if (
			error.statusCode === 429 ||
			(error.response?.body &&
				typeof error.response.body === 'object' &&
				error.response.body.message &&
				error.response.body.message.includes('rate limit'))
		) {
			// Extract retry time
			let retryAfter = '60';
			let errorMsg = '';

			if (error.response?.headers && error.response.headers['retry-after']) {
				retryAfter = error.response.headers['retry-after'];
			} else if (error.response?.body && typeof error.response.body === 'object') {
				errorMsg = error.response.body.message || '';
				const match = errorMsg.match(/(\d+) seconds/);
				if (match && match[1]) {
					retryAfter = match[1];
				}
			} else if (typeof error.error === 'object' && error.error.message) {
				errorMsg = error.error.message;
				const match = errorMsg.match(/(\d+) seconds/);
				if (match && match[1]) {
					retryAfter = match[1];
				}
			}

			const retrySeconds = parseInt(retryAfter, 10) || 60;

			handleRateLimiting(cacheKey, {
				message: errorMsg,
				retryAfter: retrySeconds,
			});

			throw new Error(
				`API rate limited during authentication. Please try again after ${retrySeconds} seconds.`,
			);
		}

		throw new Error(`Authentication failed: ${error.message}`);
	}
}

/**
 * Make an API request to Aruba Central with OAuth2 authentication
 */
export async function apiRequest(
	this: IExecuteFunctions,
	method: IHttpRequestMethods,
	endpoint: string,
	body: IDataObject = {},
	qs: IDataObject = {},
): Promise<any> {
	logger.debug('api:request', `${method} ${endpoint} started`);
	logger.debug('api:request:params', JSON.stringify(qs, null, 2));

	const credentials = await this.getCredentials('ArubaCentralOAuth2Api');

	if (!credentials) {
		logger.error('api:request', 'No credentials provided');
		throw new NodeOperationError(this.getNode(), 'No credentials provided');
	}

	logger.debug('api:request', 'Credentials loaded successfully');
	const baseUrl = credentials.baseUrl as string;

	// Print credential object structure (without sensitive values)
	const credentialKeys = Object.keys(credentials);
	logger.debug(
		'api:request:credentials',
		`Available credential keys: ${JSON.stringify(credentialKeys)}`,
	);
	if (credentials.oauthTokenData) {
		const tokenDataKeys = Object.keys(credentials.oauthTokenData as object);
		logger.debug('api:request:credentials', `Token data keys: ${JSON.stringify(tokenDataKeys)}`);
	} else {
		logger.debug('api:request:credentials', 'No oauthTokenData found in credentials');
	}

	try {
		// Get access token using the OAuth2 flow
		logger.debug('api:request', 'Getting access token');
		const accessToken = await getAccessToken.call(this, credentials);
		logger.debug('api:request', 'Successfully obtained access token');

		// Make the actual API request
		logger.debug('api:request', `Making ${method} request to ${endpoint}`);
		const requestOptions: IHttpRequestOptions = {
			method,
			url: `${baseUrl}${endpoint}`,
			headers: {
				'Content-Type': 'application/json',
				Authorization: `Bearer ${accessToken}`,
			},
			body,
			qs,
			json: true,
			resolveWithFullResponse: true,
		};

		logger.debug(
			'api:request:options',
			JSON.stringify(
				{
					method: requestOptions.method,
					url: requestOptions.url,
					headers: {
						'Content-Type': requestOptions.headers?.['Content-Type'],
						Authorization: '***',
					},
					qs: requestOptions.qs,
					body: requestOptions.body,
				},
				null,
				2,
			),
		);

		// Use n8n's request helper
		const response = await this.helpers.httpRequest(requestOptions);
		logger.debug('api:request:response', `Response received`);

		return response.body || response;
	} catch (error) {
		logger.error('api:request:exception', error.message);

		// Handle API-specific errors
		if (error.response) {
			logger.error(
				'api:request:error',
				`Status: ${error.statusCode}, Body: ${JSON.stringify(error.error)}`,
			);

			let message = 'Unknown error';
			const errorBody = error.error || {};

			// More detailed error message extraction
			if (typeof errorBody === 'object') {
				if (errorBody.description) {
					message = errorBody.description;
				} else if (errorBody.error_description) {
					message = errorBody.error_description;
				} else if (errorBody.message) {
					message = errorBody.message;
				} else if (errorBody.error) {
					message = errorBody.error;
				}
			} else if (typeof errorBody === 'string') {
				try {
					const parsedBody = JSON.parse(errorBody);
					if (parsedBody.message) {
						message = parsedBody.message;
					}
				} catch (e) {
					// If parsing fails, use the string if it's not too long
					if (errorBody.length < 300) {
						message = errorBody;
					}
				}
			}

			logger.error('api:request:error', `Formatted message: ${message}`);

			// Handle rate limiting specifically
			if (error.statusCode === 429) {
				const cacheKey = `${credentials.baseUrl}_${credentials.clientId}`;

				// Extract retry time
				let retryAfter = '60';

				if (error.response.headers && error.response.headers['retry-after']) {
					retryAfter = error.response.headers['retry-after'];
				} else if (message.includes('seconds')) {
					const match = message.match(/(\d+) seconds/);
					if (match && match[1]) {
						retryAfter = match[1];
					}
				}

				// Use the extracted retry time or a default
				const retrySeconds = parseInt(retryAfter, 10) || 60;

				handleRateLimiting(cacheKey, {
					message,
					retryAfter: retrySeconds,
				});

				throw new Error(`API rate limited. Please try again after ${retrySeconds} seconds.`);
			}

			throw new NodeApiError(this.getNode(), error, { message });
		}

		// Re-throw with original error
		logger.error('api:request:error', 'Re-throwing original error');
		throw error;
	} finally {
		logger.debug('api:request', 'Request completed');
	}
}
