/*
 * Copyright (C) 2026 Fluxer Contributors
 *
 * This file is part of Fluxer.
 *
 * Fluxer is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Fluxer is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
 */

import {HttpStatus, REDIRECT_STATUS_CODES} from '@fluxer/constants/src/HttpConstants';
import {
	buildRequestHeaders,
	classifyRequestError,
	createRequestSignal,
	resolveRequestBody,
	statusToMetricLabel,
} from '@fluxer/http_client/src/HttpClientRequestInternals';
import type {
	HttpClientMetrics,
	HttpClientTelemetry,
	HttpClientTracing,
} from '@fluxer/http_client/src/HttpClientTelemetryTypes';
import type {
	HttpClient,
	HttpClientFactoryOptions,
	HttpMethod,
	RequestOptions,
	RequestUrlPolicy,
	RequestUrlValidationContext,
	ResponseStream,
	StreamResponse,
} from '@fluxer/http_client/src/HttpClientTypes';
import {HttpError} from '@fluxer/http_client/src/HttpError';

const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_REDIRECTS = 5;
const DEFAULT_SERVICE_NAME = 'unknown';

interface ResolvedClientConfig {
	defaultHeaders: Record<string, string>;
	defaultTimeoutMs: number;
	maxRedirects: number;
	requestUrlPolicy?: RequestUrlPolicy;
	telemetry?: HttpClientTelemetry;
}

function createDefaultHeaders(userAgent: string, defaultHeaders?: Record<string, string>): Record<string, string> {
	const headers: Record<string, string> = {
		Accept: '*/*',
		'User-Agent': userAgent,
		'Cache-Control': 'no-cache, no-store, must-revalidate',
		Pragma: 'no-cache',
	};

	if (defaultHeaders) {
		for (const [key, value] of Object.entries(defaultHeaders)) {
			headers[key] = value;
		}
	}

	return headers;
}

function resolveClientConfig(
	userAgentOrOptions: string | HttpClientFactoryOptions,
	telemetry?: HttpClientTelemetry,
): ResolvedClientConfig {
	if (typeof userAgentOrOptions === 'string') {
		return {
			defaultHeaders: createDefaultHeaders(userAgentOrOptions),
			defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
			maxRedirects: DEFAULT_MAX_REDIRECTS,
			requestUrlPolicy: undefined,
			telemetry,
		};
	}

	const maxRedirects =
		typeof userAgentOrOptions.maxRedirects === 'number' && userAgentOrOptions.maxRedirects >= 0
			? userAgentOrOptions.maxRedirects
			: DEFAULT_MAX_REDIRECTS;
	const defaultTimeoutMs =
		typeof userAgentOrOptions.defaultTimeoutMs === 'number' && userAgentOrOptions.defaultTimeoutMs > 0
			? userAgentOrOptions.defaultTimeoutMs
			: DEFAULT_TIMEOUT_MS;

	return {
		defaultHeaders: createDefaultHeaders(userAgentOrOptions.userAgent, userAgentOrOptions.defaultHeaders),
		defaultTimeoutMs,
		maxRedirects,
		requestUrlPolicy: userAgentOrOptions.requestUrlPolicy,
		telemetry: userAgentOrOptions.telemetry,
	};
}

function createFetchInit(
	method: HttpMethod,
	headers: Record<string, string>,
	body: string | undefined,
	signal: AbortSignal,
): RequestInit {
	return {
		method,
		headers,
		body,
		signal,
		redirect: 'manual',
	};
}

function isRedirectStatus(status: number): boolean {
	return REDIRECT_STATUS_CODES.includes(status as (typeof REDIRECT_STATUS_CODES)[number]);
}

const SENSITIVE_REDIRECT_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization']);
const BODY_RELATED_HEADERS = new Set(['content-type', 'content-length', 'transfer-encoding']);

function shouldSwitchToGet(status: number, method: HttpMethod): boolean {
	if (status === HttpStatus.SEE_OTHER) {
		return true;
	}

	if (status === HttpStatus.MOVED_PERMANENTLY || status === HttpStatus.FOUND) {
		return method !== 'GET' && method !== 'HEAD';
	}

	return false;
}

function buildRedirectHeaders(
	headers: Record<string, string>,
	stripSensitive: boolean,
	dropBodyHeaders: boolean,
): Record<string, string> {
	const nextHeaders: Record<string, string> = {};

	for (const [key, value] of Object.entries(headers)) {
		const lowerKey = key.toLowerCase();
		if (stripSensitive && SENSITIVE_REDIRECT_HEADERS.has(lowerKey)) {
			continue;
		}
		if (dropBodyHeaders && BODY_RELATED_HEADERS.has(lowerKey)) {
			continue;
		}
		nextHeaders[key] = value;
	}

	return nextHeaders;
}

async function fetchWithRedirects(
	url: string,
	method: HttpMethod,
	headers: Record<string, string>,
	body: string | undefined,
	signal: AbortSignal,
	maxRedirects: number,
	requestUrlPolicy?: RequestUrlPolicy,
): Promise<Response> {
	let currentUrl = new URL(url);
	let currentMethod: HttpMethod = method;
	let currentBody = body;
	let currentHeaders = {...headers};
	await validateRequestUrlPolicy(requestUrlPolicy, currentUrl, {
		phase: 'initial',
		redirectCount: 0,
	});
	let response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal));
	let redirectCount = 0;

	while (isRedirectStatus(response.status)) {
		if (redirectCount >= maxRedirects) {
			throw new HttpError(`Maximum number of redirects (${maxRedirects}) exceeded`);
		}

		const location = response.headers.get('location');
		if (!location) {
			throw new HttpError('Received redirect response without Location header', response.status);
		}

		const previousUrl = currentUrl;
		const nextUrl = new URL(location, response.url || currentUrl.href);
		const switchToGet = shouldSwitchToGet(response.status, currentMethod);
		if (switchToGet) {
			currentMethod = 'GET';
			currentBody = undefined;
		}

		const previousOrigin = previousUrl.origin;
		const nextOrigin = nextUrl.origin;
		const stripSensitive = previousOrigin !== nextOrigin;
		currentHeaders = buildRedirectHeaders(currentHeaders, stripSensitive, switchToGet);
		const nextRedirectCount = redirectCount + 1;
		await validateRequestUrlPolicy(requestUrlPolicy, nextUrl, {
			phase: 'redirect',
			redirectCount: nextRedirectCount,
			previousUrl: previousUrl.href,
		});
		currentUrl = nextUrl;

		response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal));
		redirectCount = nextRedirectCount;
	}

	return response;
}

async function validateRequestUrlPolicy(
	requestUrlPolicy: RequestUrlPolicy | undefined,
	url: URL,
	context: RequestUrlValidationContext,
): Promise<void> {
	if (!requestUrlPolicy) {
		return;
	}

	await requestUrlPolicy.validate(url, context);
}

async function runWithTracing<T>(
	tracing: HttpClientTracing | undefined,
	method: HttpMethod,
	url: string,
	serviceName: string,
	fn: () => Promise<T>,
): Promise<T> {
	if (!tracing) {
		return fn();
	}

	return tracing.withSpan(
		{
			name: 'http_client.fetch',
			attributes: {
				'http.request.method': method,
				'url.full': url,
				'service.name': serviceName,
			},
		},
		fn,
	);
}

function recordSuccessfulRequestMetrics(
	metrics: HttpClientMetrics | undefined,
	serviceName: string,
	method: HttpMethod,
	status: number,
	durationMs: number,
): void {
	if (!metrics) {
		return;
	}

	const statusCategory = statusToMetricLabel(status);
	metrics.histogram({
		name: 'http_client.latency',
		dimensions: {service: serviceName, method},
		valueMs: durationMs,
	});
	metrics.counter({
		name: 'http_client.request',
		dimensions: {service: serviceName, method, status: statusCategory},
	});
	metrics.counter({
		name: 'http_client.response',
		dimensions: {service: serviceName, status_code: statusCategory},
	});
}

function recordHttpErrorMetrics(
	metrics: HttpClientMetrics | undefined,
	serviceName: string,
	method: HttpMethod,
	status: string,
	durationMs: number,
): void {
	if (!metrics) {
		return;
	}

	metrics.counter({
		name: 'http_client.request',
		dimensions: {service: serviceName, method, status},
	});
	metrics.histogram({
		name: 'http_client.latency',
		dimensions: {service: serviceName, method},
		valueMs: durationMs,
	});
}

function recordUnhandledErrorMetrics(
	metrics: HttpClientMetrics | undefined,
	serviceName: string,
	method: HttpMethod,
	status: string,
	errorType: string,
	durationMs: number,
): void {
	if (!metrics) {
		return;
	}

	metrics.counter({
		name: 'http_client.request',
		dimensions: {service: serviceName, method, status},
	});
	metrics.histogram({
		name: 'http_client.latency',
		dimensions: {service: serviceName, method, error_type: errorType},
		valueMs: durationMs,
	});
}

export function createHttpClient(userAgent: string, telemetry?: HttpClientTelemetry): HttpClient;
export function createHttpClient(options: HttpClientFactoryOptions): HttpClient;
export function createHttpClient(
	userAgentOrOptions: string | HttpClientFactoryOptions,
	telemetry?: HttpClientTelemetry,
): HttpClient {
	const config = resolveClientConfig(userAgentOrOptions, telemetry);
	const metrics = config.telemetry?.metrics;
	const tracing = config.telemetry?.tracing;

	async function request(opts: RequestOptions): Promise<StreamResponse> {
		const startTime = Date.now();
		const method: HttpMethod = opts.method ?? 'GET';
		const serviceName = opts.serviceName ?? DEFAULT_SERVICE_NAME;
		const timeoutMs = typeof opts.timeout === 'number' && opts.timeout > 0 ? opts.timeout : config.defaultTimeoutMs;

		const requestSignal = createRequestSignal(timeoutMs, opts.signal);
		const headers = buildRequestHeaders(config.defaultHeaders, opts.headers);
		const body = resolveRequestBody(opts.body, headers);

		try {
			const response = await runWithTracing(tracing, method, opts.url, serviceName, async () => {
				return fetchWithRedirects(
					opts.url,
					method,
					headers,
					body,
					requestSignal.signal,
					config.maxRedirects,
					config.requestUrlPolicy,
				);
			});

			const result: StreamResponse = {
				stream: response.body,
				headers: response.headers,
				status: response.status,
				url: response.url || opts.url,
			};

			const durationMs = Date.now() - startTime;
			recordSuccessfulRequestMetrics(metrics, serviceName, method, result.status, durationMs);
			return result;
		} catch (error) {
			const durationMs = Date.now() - startTime;
			if (error instanceof HttpError) {
				recordHttpErrorMetrics(metrics, serviceName, method, error.status?.toString() ?? 'error', durationMs);
				throw error;
			}

			const classifiedError = classifyRequestError(error);
			const status = classifiedError.isNetworkError ? 'network_error' : 'error';
			recordUnhandledErrorMetrics(metrics, serviceName, method, status, classifiedError.errorType, durationMs);
			throw new HttpError(
				classifiedError.message,
				undefined,
				undefined,
				classifiedError.isNetworkError,
				classifiedError.errorType,
			);
		} finally {
			requestSignal.cleanup();
		}
	}

	async function sendRequest(opts: RequestOptions): Promise<StreamResponse> {
		return request(opts);
	}

	async function streamToString(stream: ResponseStream): Promise<string> {
		if (!stream) {
			return '';
		}
		return new Response(stream).text();
	}

	return {
		request,
		sendRequest,
		streamToString,
	};
}
