import { Sha256 } from '@aws-crypto/sha256-js';
import { createHttpsProxyAgent, resolveProxyUrl } from '@n8n/backend-network/proxy';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { HttpRequest } from '@smithy/protocol-http';
import { SignatureV4 } from '@smithy/signature-v4';
import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@smithy/types';
import type { Request } from 'aws4';
import { sign } from 'aws4';
import {
	type IHttpRequestMethods,
	isObjectEmpty,
	type ICredentialTestRequest,
	type IDataObject,
	type IHttpRequestOptions,
	type IRequestOptions,
	UserError,
} from 'n8n-workflow';

import { getAwsDomain, regions, type AWSRegion } from './regions';
import { getSystemCredentials } from './system-credentials-utils';
import type {
	AwsCredentialsTypeBase,
	AwsAssumeRoleCredentialsType,
	AwsSecurityHeaders,
} from './types';

// ── Private ──────────────────────────────────────────────────────────────────

const SUPPORTED_AWS_REGIONS: ReadonlySet<string> = new Set(regions.map((r) => r.name));

/**
 * Checks if a request body value should be JSON stringified for AWS requests.
 * Returns true for plain objects without Content-Length headers.
 */
function shouldStringifyBody<T>(value: T, headers: IDataObject): boolean {
	if (
		typeof value === 'object' &&
		value !== null &&
		!headers['Content-Length'] &&
		!headers['content-length'] &&
		!Buffer.isBuffer(value)
	) {
		return true;
	}

	return false;
}

/**
 * Maps an AWS endpoint subdomain to its SigV4 signing service name.
 *
 * Most AWS endpoints sign with the same name as their hostname label, but
 * some service families (notably Amazon Bedrock) expose multiple endpoint
 * subdomains that all sign against a single `signingName`. Without this
 * mapping, `aws4` would derive the signing name from the host and AWS would
 * reject the request with `SignatureDoesNotMatch`.
 *
 * Endpoints that already match their signing name fall through unchanged.
 *
 * @param service - Service name as extracted from the endpoint hostname
 * @returns The SigV4 signing service name
 */
function getAwsSigningService(service: string): string {
	// FIPS endpoints (`<service>-fips.<region>.amazonaws.com`) sign with the base
	// service name; e.g. `s3-fips` signed as-is fails with SignatureDoesNotMatch.
	const baseService = service.replace(/-fips$/, '');
	// Virtual-hosted-style S3 requests arrive as `<bucket>.s3` (the node builds the
	// endpoint `<bucket>.s3.<region>.amazonaws.com`). They all sign under the `s3`
	// signing name, as do S3 access-point (`s3-accesspoint`) and S3 Control
	// (`s3-control`) endpoints — bare or with a bucket/access-point qualifier
	// prefix. aws4 derived this by inspecting the host; smithy does not, so we
	// normalize it here.
	if (/(^|\.)s3(-accesspoint|-control|-accelerate)?$/.test(baseService)) {
		return 's3';
	}
	switch (baseService) {
		// Mirror AWS SDK Bedrock signing for HTTP Request node AWS credentials:
		// these endpoint families are signed with the `bedrock` service namespace.
		// https://docs.aws.amazon.com/bedrock/latest/APIReference/welcome.html#API_Reference_Endpoints
		// https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonbedrock.html
		case 'bedrock-runtime':
		case 'bedrock-agent':
		case 'bedrock-agent-runtime':
		case 'bedrock-data-automation':
		case 'bedrock-data-automation-runtime':
			return 'bedrock';
		// Legacy region-first SQS endpoints (`<region>.queue.amazonaws.com`).
		case 'queue':
			return 'sqs';
		// SES (v1 and v2) endpoints are `email.<region>.amazonaws.com` but sign
		// under `ses`. aws4 remapped this inside its RequestSigner; smithy signs
		// the name it is given, so the mapping must live here.
		case 'email':
			return 'ses';
		default:
			return baseService;
	}
}

/**
 * Validates required role fields and returns their trimmed values.
 * `externalId` and `roleSessionName` are optional in the type and the STS API;
 * absent values are returned as `undefined` / the STS default rather than rejected.
 */
function assertValidRoleCredentials(credentials: AwsAssumeRoleCredentialsType): {
	roleArn: string;
	externalId: string | undefined;
	roleSessionName: string;
} {
	if (!credentials.roleArn || credentials.roleArn.trim() === '') {
		throw new UserError('Role ARN is required when assuming a role.');
	}
	return {
		roleArn: credentials.roleArn.trim(),
		externalId: credentials.externalId?.trim() || undefined,
		roleSessionName: credentials.roleSessionName?.trim() || 'n8n-session',
	};
}

/**
 * Returns an `AwsCredentialIdentityProvider` for the STS AssumeRole call.
 * Uses system credentials (env / IMDS / container) when `useSystemCredentialsForRole` is set,
 * otherwise validates and wraps the explicitly provided STS key pair.
 */
function buildMasterCredentials(
	credentials: AwsAssumeRoleCredentialsType,
	region: AWSRegion,
): AwsCredentialIdentityProvider {
	if (credentials.useSystemCredentialsForRole) {
		return async () => {
			const sys = await getSystemCredentials(region);
			if (!sys) {
				throw new UserError(
					'System AWS credentials are required for role assumption. Please ensure AWS credentials are available via environment variables, instance metadata, or container role.',
				);
			}
			return {
				accessKeyId: sys.accessKeyId,
				secretAccessKey: sys.secretAccessKey,
				...(sys.sessionToken ? { sessionToken: sys.sessionToken } : {}),
			};
		};
	}

	if (!credentials.stsAccessKeyId || credentials.stsAccessKeyId.trim() === '') {
		throw new UserError('STS Access Key ID is required when not using system credentials.');
	}
	if (!credentials.stsSecretAccessKey || credentials.stsSecretAccessKey.trim() === '') {
		throw new UserError('STS Secret Access Key is required when not using system credentials.');
	}
	const identity: AwsCredentialIdentity = {
		accessKeyId: credentials.stsAccessKeyId.trim(),
		secretAccessKey: credentials.stsSecretAccessKey.trim(),
		...(credentials.stsSessionToken?.trim()
			? { sessionToken: credentials.stsSessionToken.trim() }
			: {}),
	};
	// eslint-disable-next-line @typescript-eslint/promise-function-async
	return () => Promise.resolve(identity);
}

/**
 * Returns a proxy-aware `NodeHttpHandler` for the STS client, or `undefined` when no proxy applies.
 */
function buildStsRequestHandler(region: AWSRegion): NodeHttpHandler | undefined {
	const stsTarget = `https://sts.${region}.${getAwsDomain(region)}`;
	const proxyUrl = resolveProxyUrl(stsTarget);
	if (!proxyUrl) return undefined;
	// STS is always HTTPS; one agent backs both http/https slots.
	const proxyAgent = createHttpsProxyAgent(stsTarget, proxyUrl);
	return new NodeHttpHandler({ httpAgent: proxyAgent, httpsAgent: proxyAgent });
}

// ── Exports ───────────────────────────────────────────────────────────────────

/**
 * AWS credentials test configuration for validating AWS credentials.
 * Uses the STS GetCallerIdentity action to verify that the provided credentials are valid.
 * Automatically handles both standard AWS regions and China regions with appropriate endpoints.
 */
export const awsCredentialsTest: ICredentialTestRequest = {
	request: {
		baseURL:
			// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
			'={{$credentials.region.startsWith("cn-") ? `https://sts.${$credentials.region}.amazonaws.com.cn` : `https://sts.${$credentials.region}.amazonaws.com`}}',
		url: '?Action=GetCallerIdentity&Version=2011-06-15',
		method: 'POST',
	},
};

/**
 * PrivateLink (VPC interface endpoint) hostnames shift the service and region
 * one label to the right compared to public endpoints, e.g.
 * `vpce-0abc123[-az].bedrock-runtime.us-east-1.vpce.amazonaws.com`. The optional
 * `.cn` suffix covers the China regions, whose endpoints use `vpce.amazonaws.com.cn`.
 *
 * Only the endpoint-specific hostname (which starts with the `vpce-` id label) is
 * matched. S3's bucket/access-point/control interface endpoints prefix another
 * label before the id (e.g. `bucket.vpce-0abc123.s3.us-east-1.vpce.amazonaws.com`)
 * and are intentionally not covered here.
 *
 * @see {@link https://docs.aws.amazon.com/vpc/latest/privatelink/privatelink-share-your-services.html AWS PrivateLink}
 */
const VPCE_HOSTNAME_PATTERN = /^vpce-[^.]+\.([^.]+)\.([^.]+)\.vpce\.amazonaws\.com(?:\.cn)?$/;

function isSupportedAwsRegion(region: string): region is AWSRegion {
	return SUPPORTED_AWS_REGIONS.has(region);
}

/**
 * Matches genuine AWS endpoint hosts (public and China partitions). Custom
 * hostnames (API Gateway custom domains, proxies, S3-compatible stores) don't
 * match, so labels mis-parsed as a region from them must not fail the request.
 */
function isAwsEndpointHostname(hostname: string): boolean {
	return /\.amazonaws\.com(\.cn)?$/i.test(hostname);
}

/**
 * Ensures the region value belongs to the supported AWS regions list before it
 * is interpolated into request URLs or signing options. Anything outside the
 * known set is rejected with a controlled error.
 */
export function assertSupportedAwsRegion(region: unknown): asserts region is AWSRegion {
	if (typeof region !== 'string' || !isSupportedAwsRegion(region)) {
		throw new UserError('Unsupported AWS region');
	}
}

/**
 * Validates a user-supplied Bedrock endpoint override and returns the resolved URL.
 * Substitutes the `{region}` placeholder after the region itself is validated, and
 * requires an `http:`/`https:` scheme. The value is credential-holder-configured and
 * treated as trusted; no host allow-listing / SSRF check is applied on this path.
 *
 * @throws {UserError} When the region is unsupported, the URL is malformed, or the scheme is not http/https.
 */
export function validateBedrockEndpointOverride(override: string, region: AWSRegion): string {
	assertSupportedAwsRegion(region);
	const resolved = override.replace(/\{region\}/g, region);
	let url: URL;
	try {
		url = new URL(resolved);
	} catch {
		// Don't echo the raw value; it may contain URL userinfo (user:pass@host).
		throw new UserError('Bedrock endpoint is not a valid URL');
	}
	if (url.protocol !== 'http:' && url.protocol !== 'https:') {
		throw new UserError('Bedrock endpoint must use the http or https scheme');
	}
	// Strip a trailing slash only: on the SDK client's `endpoint` it would serialize
	// operation paths as `//model/...`. Everything else the user configured is preserved.
	return url.toString().replace(/\/$/, '');
}

/**
 * Shape of an AWS region label: a 2-4 letter partition prefix (`us`, `eusc`),
 * one or more word components, and a numeric suffix (`us-east-1`,
 * `us-gov-west-1`, `eusc-de-east-1`). Deliberately shape-only: a mistyped or
 * not-yet-supported region must reach the caller's validation instead of
 * being silently dropped.
 */
export const AWS_REGION_SHAPE_PATTERN = /^[a-z]{2,4}(-[a-z]+)+-\d+$/;

/**
 * Legacy dash-region S3 endpoints (`[<bucket>.]s3-<region>.amazonaws.com`)
 * encode the region inside the s3 label. Returns that region, or null when
 * the label doesn't carry one (`s3-accelerate`, `s3-external-1`).
 */
function parseLegacyS3DashRegion(label: string): string | null {
	if (!label.startsWith('s3-')) return null;
	const rest = label.slice(3);
	return AWS_REGION_SHAPE_PATTERN.test(rest) ? rest : null;
}

/**
 * Parses an AWS service URL to extract the service name and region.
 * Some AWS services are global and don't have a region. PrivateLink
 * endpoints (`vpce-<id>.<service>.<region>.vpce.amazonaws.com`) return their
 * positional service and region labels verbatim.
 *
 * On all other hostnames (public AWS endpoints, including dual-stack and
 * FIPS variants, and custom hosts) the region is the rightmost region-shaped
 * label, or null when no label matches. The service is the label right of
 * the region when the region is second-to-last on an AWS host (region-middle
 * and legacy region-first shapes: `<domain>.<region>.es`, `<region>.queue`);
 * otherwise the label left of the region (skipping a `dualstack` qualifier)
 * when qualifier labels such as a bucket name or API id precede it;
 * otherwise a trailing legacy region-less S3 service label (`<bucket>.s3`,
 * `<bucket>.s3-accelerate[.dualstack]`); otherwise the first label. Legacy
 * S3 shapes are special-cased: a region-shaped first label in front of
 * `s3`/`s3-accelerate` is a bucket name (null region), and dash-region
 * labels (`[<bucket>.]s3-<region>`) yield service `s3` with the embedded
 * region. The region is not validated against the supported region list;
 * callers must check it (e.g. with {@link assertSupportedAwsRegion}) before
 * using it for signing.
 *
 * @param url - The AWS service URL to parse
 * @returns Object containing the service name and region (null for global services)
 *
 * @see {@link https://docs.aws.amazon.com/general/latest/gr/rande.html#global-endpoints AWS Global Endpoints}
 */
export function parseAwsUrl(url: URL): { region: string | null; service: string } {
	const hostname = url.hostname;
	const vpceMatch = hostname.match(VPCE_HOSTNAME_PATTERN);
	if (vpceMatch) {
		const [, service, region] = vpceMatch;
		return { service, region };
	}
	// Handle both .amazonaws.com and .amazonaws.com.cn domains
	const labels = hostname.replace(/\.amazonaws\.com.*$/, '').split('.');
	// The region is the rightmost region-shaped label: AWS puts the region closest to
	// the domain suffix, and supported-ness is the caller's decision — checking it here
	// would let a bucket/qualifier label that happens to be a known region shadow a
	// mistyped or not-yet-supported label in the real region slot.
	let regionIdx = -1;
	for (let i = labels.length - 1; i >= 0; i--) {
		if (AWS_REGION_SHAPE_PATTERN.test(labels[i])) {
			regionIdx = i;
			break;
		}
	}
	const region = regionIdx === -1 ? null : labels[regionIdx];
	let service = labels[0];
	if (
		regionIdx !== -1 &&
		regionIdx === labels.length - 2 &&
		isAwsEndpointHostname(hostname) &&
		labels[regionIdx + 1] !== 'vpce'
	) {
		const next = labels[regionIdx + 1];
		if (regionIdx === 0) {
			// S3 never had a region-first shape, so a shaped first label in front of
			// an S3 service label is a bucket name, not the region.
			if (next === 's3' || next === 's3-accelerate') {
				return { service: next, region: null };
			}
			const dashRegion = parseLegacyS3DashRegion(next);
			if (dashRegion) {
				return { service: 's3', region: dashRegion };
			}
		}
		// On AWS hosts the region is otherwise always the last label before the domain
		// suffix, so a second-to-last region marks the region-middle and region-first
		// shapes (`<domain>.<region>.es.amazonaws.com`, `<region>.queue.amazonaws.com`),
		// which put the service right of the region. Bucket-qualified S3 interface
		// endpoints (`<bucket>.vpce-<id>.s3.<region>.vpce.amazonaws.com`) also carry a
		// second-to-last region but their trailing `vpce` label is not a service.
		service = next;
	} else if (regionIdx >= 2) {
		// AWS hostnames place the service label immediately left of the region
		// (qualifiers like bucket/API-id/access-point names sit further left);
		// dual-stack endpoints interpose a 'dualstack' qualifier — skip it.
		let serviceIdx = regionIdx - 1;
		if (labels[serviceIdx] === 'dualstack') serviceIdx--;
		service = labels[serviceIdx];
	} else if (regionIdx === -1) {
		// Legacy region-less S3 hosts (`<bucket>.s3.amazonaws.com`,
		// `<bucket>.s3-accelerate[.dualstack].amazonaws.com`) put the service last.
		// The family is closed, so only adopt a trailing label that belongs to it —
		// a host with a typo'd (non-region-shaped) region keeps its first-label service.
		let serviceIdx = labels.length - 1;
		if (labels[serviceIdx] === 'dualstack' && serviceIdx > 0) serviceIdx--;
		const candidate = labels[serviceIdx];
		const dashRegion = parseLegacyS3DashRegion(candidate);
		if (dashRegion) {
			return { service: 's3', region: dashRegion };
		}
		if (serviceIdx > 0 && (candidate === 's3' || candidate === 's3-accelerate')) {
			service = candidate;
		}
	}
	return { service, region };
}

/**
 * Derives the signing service and region from a request URL, without
 * regressing a caller-supplied value.
 *
 * - `service` is only taken from the URL when the caller didn't already
 *   supply one (e.g. via qs.service). This lets callers force a signing
 *   service that URL parsing can't reliably infer, without regressing
 *   callers that rely on the URL as the source of truth (the common case:
 *   no qs.service is set).
 * - `region` is only taken from the URL when it's a recognized AWS region. On
 *   an AWS endpoint host, an unrecognized label (a malformed/mistyped host, or
 *   an odd endpoint shape the parser mis-split) throws a UserError, so the
 *   request fails fast with a clear message instead of signing with a bad
 *   region. On a custom (non-AWS) host, an unrecognized region-shaped label
 *   is not authoritative (proxies and S3-compatible stores use their own
 *   region names), so the credential region is kept instead.
 */
function resolveServiceAndRegion(
	url: URL,
	service: string,
	region: AWSRegion,
): { service: string; region: AWSRegion } {
	const parsed = parseAwsUrl(url);
	const resolvedService = service || parsed.service;
	let resolvedRegion = region;
	if (parsed.region) {
		if (isSupportedAwsRegion(parsed.region)) {
			resolvedRegion = parsed.region;
		} else if (isAwsEndpointHostname(url.hostname)) {
			throw new UserError(
				`Unsupported AWS region "${parsed.region}" parsed from endpoint host ${url.hostname}`,
			);
		}
	}
	return { service: resolvedService, region: resolvedRegion };
}

/**
 * Applies AWS's S3 `UriEncode` canonicalization to a URL pathname: each segment is
 * decoded, then percent-encoded so that only RFC 3986 unreserved characters and the
 * `/` separators stay literal — the form S3 computes server-side when verifying
 * SigV4 signatures, and the form the AWS SDK sends on the wire. A segment that is
 * not valid percent-encoding (a stray `%`) is treated as raw text, so the key still
 * round-trips unchanged. An encoded slash collapses to `/` (S3 keys are flat, so
 * `%2F` and `/` address the same key — aws4 did the same). A literal `+` becomes
 * `%2B` (AWS SDK behavior); the legacy aws4 signer read a path `+` as a space.
 */
export function uriEncodeS3Pathname(pathname: string): string {
	return pathname
		.split('/')
		.map((segment) => {
			// Decode runs of percent-escapes rather than the whole segment: a stray `%`
			// (not valid encoding) then stays literal text and is encoded below, instead
			// of poisoning the valid escapes around it.
			const decoded = segment.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
				try {
					return decodeURIComponent(run);
				} catch {
					// e.g. malformed UTF-8 byte sequences — keep the run as literal text
					return run;
				}
			});
			return encodeURIComponent(decoded).replace(
				/[!'()*]/g,
				(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
			);
		})
		.join('/')
		.replace(/%2F/g, '/');
}

/**
 * Prepares AWS request options for signing by constructing the proper endpoint URL,
 * handling query parameters, and setting up the request body for AWS4 signature.
 *
 * This function handles multiple scenarios:
 * - Custom service endpoints from credentials
 * - Default AWS service endpoints
 * - URI-based requests (legacy IRequestOptions interface)
 * - Form data conversion to URL-encoded format
 * - Special handling for STS GetCallerIdentity requests
 *
 * @param requestOptions - The HTTP request options to modify
 * @param credentials - AWS credentials containing potential custom endpoints
 * @param path - The API path to append to the endpoint
 * @param method - HTTP method for the request
 * @param service - AWS service name (e.g., 's3', 'lambda', 'sts')
 * @param region - AWS region for the request
 * @returns Object containing signing options and the constructed endpoint URL
 */
export function awsGetSignInOptionsAndUpdateRequest(
	requestOptions: IHttpRequestOptions,
	credentials: AwsCredentialsTypeBase,
	path: string,
	method: string | undefined,
	service: string,
	region: AWSRegion,
): { signOpts: Request; url: string } {
	assertSupportedAwsRegion(region);
	let body = requestOptions.body;
	let endpoint: URL;
	let query = requestOptions.qs?.query as IDataObject;
	// ! Workaround as we still use the IRequestOptions interface which uses uri instead of url
	// ! To change when we replace the interface with IHttpRequestOptions
	const requestWithUri = requestOptions as unknown as IRequestOptions;
	if (requestWithUri.uri) {
		requestOptions.url = requestWithUri.uri;
		endpoint = new URL(requestOptions.url);
		if (service === 'sts') {
			try {
				if (requestWithUri.qs?.Action !== 'GetCallerIdentity') {
					query = requestWithUri.qs as IDataObject;
				} else {
					endpoint.searchParams.set('Action', 'GetCallerIdentity');
					endpoint.searchParams.set('Version', '2011-06-15');
				}
			} catch (err) {
				console.error(err);
			}
		} else {
			// UI Query Parameters are stored at the top level of requestOptions.qs, not under
			// a nested `query` key, so merge the whole object to sign and send them.
			query = requestWithUri.qs as IDataObject;
		}
		({ service, region } = resolveServiceAndRegion(endpoint, service, region));
	} else {
		if (!requestOptions.baseURL && !requestOptions.url) {
			let endpointString: string;
			if (service === 'lambda' && credentials.lambdaEndpoint) {
				endpointString = credentials.lambdaEndpoint;
			} else if (service === 'sns' && credentials.snsEndpoint) {
				endpointString = credentials.snsEndpoint;
			} else if (service === 'sqs' && credentials.sqsEndpoint) {
				endpointString = credentials.sqsEndpoint;
			} else if (service === 's3' && credentials.s3Endpoint) {
				endpointString = credentials.s3Endpoint;
			} else if (service === 'ses' && credentials.sesEndpoint) {
				endpointString = credentials.sesEndpoint;
			} else if (service === 'rekognition' && credentials.rekognitionEndpoint) {
				endpointString = credentials.rekognitionEndpoint;
			} else if (service === 'ssm' && credentials.ssmEndpoint) {
				endpointString = credentials.ssmEndpoint;
			} else if (service) {
				const domain = getAwsDomain(region);
				endpointString = `https://${service}.${region}.${domain}`;
			}
			endpoint = new URL(endpointString!.replace('{region}', region) + path);
		} else {
			// If no endpoint is set, we try to decompose the path and use the default endpoint
			const customUrl = new URL(`${requestOptions.baseURL!}${requestOptions.url}${path}`);
			({ service, region } = resolveServiceAndRegion(customUrl, service, region));
			// Swap only the host: signing service and region stay derived from the default
			// host above, so a custom endpoint can never change how the request is signed.
			if (service === 'bedrock' && credentials.bedrockEndpoint) {
				const override = new URL(
					validateBedrockEndpointOverride(credentials.bedrockEndpoint, region),
				);
				customUrl.protocol = override.protocol;
				customUrl.host = override.host;
				if (override.pathname !== '/') {
					customUrl.pathname = override.pathname.replace(/\/+$/, '') + customUrl.pathname;
				}
			}
			if (service === 'sts') {
				try {
					customUrl.searchParams.set('Action', 'GetCallerIdentity');
					customUrl.searchParams.set('Version', '2011-06-15');
				} catch (err) {
					console.error(err);
				}
			}
			endpoint = customUrl;
		}
	}

	if (query && Object.keys(query).length !== 0) {
		Object.keys(query).forEach((key) => {
			endpoint.searchParams.append(key, query[key] as string);
		});
	}

	if (body && typeof body === 'object' && isObjectEmpty(body)) {
		body = '';
	}

	const signingService = getAwsSigningService(service);

	// S3 verifies the signature against the strictly encoded object path, and
	// uriEscapePath is off for S3 so smithy signs this string verbatim. WHATWG URL
	// leaves characters like ( ) + & = : @ raw in the pathname, so encode it once
	// here — the same string becomes both the signed path and the wire URL below.
	// The legacy signer must keep the raw path: it canonicalizes internally, and
	// the rollback flag has to reproduce pre-migration wire bytes exactly.
	const encodeS3Path = signingService === 's3' && process.env.N8N_AWS_LEGACY_SIGNER !== 'true';
	path =
		(encodeS3Path ? uriEncodeS3Pathname(endpoint.pathname) : endpoint.pathname) + endpoint.search;

	// ! aws4.sign *must* have the body to sign, but we might have .form instead of .body
	const requestWithForm = requestOptions as unknown as { form?: Record<string, string> };
	let bodyContent = body !== '' ? body : undefined;
	let contentTypeHeader: string | undefined = undefined;

	if (shouldStringifyBody(bodyContent, requestOptions.headers ?? {})) {
		bodyContent = JSON.stringify(bodyContent);
	}

	if (requestWithForm.form) {
		const params = new URLSearchParams();
		for (const key in requestWithForm.form) {
			params.append(key, requestWithForm.form[key]);
		}
		bodyContent = params.toString();
		contentTypeHeader = 'application/x-www-form-urlencoded';
	}

	const signOpts = {
		...requestOptions,
		headers: {
			...(requestOptions.headers ?? {}),
			...(contentTypeHeader && { 'content-type': contentTypeHeader }),
		},
		host: endpoint.host,
		method,
		path,
		body: bodyContent,
		region,
		// Always carry the resolved signing service. The signer must not re-derive it
		// from the hostname: virtual-hosted S3 (bucket.s3.<region>.amazonaws.com) would
		// yield the bucket name instead of 's3', breaking the signature and the
		// S3-specific signing rules.
		service: signingService,
	} as unknown as Request;

	return { signOpts, url: endpoint.origin + path };
}

/**
 * Assumes an AWS IAM role via STS and returns temporary credentials.
 * Supports two master-credential modes: system credentials (env / IMDS / container)
 * or an explicitly provided STS key pair.
 *
 * @throws {UserError} When inputs fail validation or the STS AssumeRole call is rejected.
 * @see {@link https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html}
 */
export async function assumeRole(
	credentials: AwsAssumeRoleCredentialsType,
	region: AWSRegion,
): Promise<{
	accessKeyId: string;
	secretAccessKey: string;
	sessionToken: string;
}> {
	assertSupportedAwsRegion(region);
	const { roleArn, externalId, roleSessionName } = assertValidRoleCredentials(credentials);

	const masterCredentials = buildMasterCredentials(credentials, region);
	const requestHandler = buildStsRequestHandler(region);

	// Lazy-load the AWS SDK so the ~1.5 MB umbrella (Cognito/SSO clients) isn't
	// pulled in at startup for workflows that never assume an AWS role.
	const { fromTemporaryCredentials } = await import('@aws-sdk/credential-providers');
	const provider = fromTemporaryCredentials({
		// eslint-disable-next-line @typescript-eslint/naming-convention
		params: {
			RoleArn: roleArn,
			RoleSessionName: roleSessionName,
			...(externalId ? { ExternalId: externalId } : {}),
		},
		masterCredentials,
		clientConfig: {
			region,
			maxAttempts: 1,
			requestHandler: requestHandler ?? { requestTimeout: 2000, connectionTimeout: 2000 },
		},
	});

	try {
		const resolved = await provider();
		return {
			accessKeyId: resolved.accessKeyId,
			secretAccessKey: resolved.secretAccessKey,
			sessionToken: resolved.sessionToken ?? '',
		};
	} catch (err) {
		if (err instanceof UserError) throw err;
		const message = err instanceof Error ? err.message : String(err);
		throw new UserError(`STS AssumeRole failed: ${message}`);
	}
}

// Splits a path+search string into the pathname and a query parameter map.
// smithy's SignatureV4 requires query params as a separate object, not embedded in the path.
// The path is sliced raw (not run through URL) to preserve exact bytes, which S3 signing
// depends on. Query parsing uses URLSearchParams; repeated keys are kept as arrays since
// smithy accepts string | string[].
export function splitPathAndQuery(pathWithSearch: string): {
	path: string;
	query: Record<string, string | string[]>;
} {
	const idx = pathWithSearch.indexOf('?');
	if (idx === -1) return { path: pathWithSearch, query: {} };

	const query: Record<string, string | string[]> = {};
	for (const [key, value] of new URLSearchParams(pathWithSearch.slice(idx + 1))) {
		const existing = query[key];
		if (existing === undefined) query[key] = value;
		else query[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
	}

	return { path: pathWithSearch.slice(0, idx), query };
}

// Splits a `host[:port]` string into hostname and numeric port, bracket-aware for
// IPv6 literals (e.g. `[::1]:4566`). A naive `.split(':')` would cut an IPv6
// address at its first colon.
export function splitHostPort(host: string): { hostname: string; port: number | undefined } {
	if (host.startsWith('[')) {
		const closeIdx = host.indexOf(']');
		if (closeIdx !== -1) {
			const hostname = host.slice(0, closeIdx + 1);
			const rest = host.slice(closeIdx + 1);
			const port = rest.startsWith(':') ? parseInt(rest.slice(1), 10) : undefined;
			return { hostname, port };
		}
	}
	const [hostname, portStr] = host.split(':');
	return { hostname, port: portStr ? parseInt(portStr, 10) : undefined };
}

// Legacy aws4 signer, kept behind N8N_AWS_LEGACY_SIGNER as an operator rollback
// lever. Temporary: removed together with aws4 once the smithy path has soaked.
function signWithLegacyAws4(
	requestOptions: IHttpRequestOptions,
	signOpts: Request,
	securityHeaders: AwsSecurityHeaders,
	url: string,
	method?: IHttpRequestMethods,
): IHttpRequestOptions {
	// Let signing errors propagate. Continuing with an unsigned request only yields
	// an opaque 403 from AWS that hides the real cause.
	sign(signOpts, securityHeaders);
	return {
		...requestOptions,
		headers: signOpts.headers,
		method,
		url,
		body: signOpts.body,
		qs: undefined,
	};
}

// Translates n8n's aws4-shaped Request into a smithy HttpRequest: splits the query
// out of the path, lowercases header keys (so smithy's canonical sort is stable),
// and mirrors aws4's content-type/length injection for body requests.
export function buildSmithyHttpRequest(
	signOpts: Request,
	method?: IHttpRequestMethods,
): HttpRequest {
	const { path, query } = splitPathAndQuery(signOpts.path ?? '/');
	const { hostname, port } = splitHostPort(signOpts.host ?? '');

	// Drop host; smithy derives it from hostname.
	// smithy calls .trim() on each one — so coerce to
	// strings like aws4 did: stringify scalars, join arrays, drop undefined.
	const headers: Record<string, string> = { host: signOpts.host ?? hostname };
	for (const [k, v] of Object.entries(signOpts.headers ?? {})) {
		const lower = k.toLowerCase();
		if (lower === 'host' || v === undefined || v === null) continue;
		headers[lower] = Array.isArray(v) ? v.join(',') : String(v);
	}

	// aws4 defaults the Content-Type to 'application/x-www-form-urlencoded; charset=utf-8'
	// (not json) and sets Content-Length for any truthy body, string or Buffer;
	// smithy injects neither.
	const body = signOpts.body;
	const hasBody = (typeof body === 'string' && body.length > 0) || Buffer.isBuffer(body);
	if (hasBody) {
		if (!headers['content-type']) {
			headers['content-type'] = 'application/x-www-form-urlencoded; charset=utf-8';
		}
		if (!headers['content-length']) {
			headers['content-length'] = String(Buffer.byteLength(body as string | Buffer));
		}
	}

	return new HttpRequest({
		method: (signOpts.method ?? method ?? 'GET').toUpperCase(),
		hostname,
		...(port !== undefined && { port }),
		path: path || '/',
		...(Object.keys(query).length > 0 && { query }),
		headers,
		body: signOpts.body ?? undefined,
		protocol: 'https:',
	});
}

export async function signOptions(
	requestOptions: IHttpRequestOptions,
	signOpts: Request,
	securityHeaders: AwsSecurityHeaders,
	url: string,
	method?: IHttpRequestMethods,
): Promise<IHttpRequestOptions> {
	if (process.env.N8N_AWS_LEGACY_SIGNER === 'true') {
		return signWithLegacyAws4(requestOptions, signOpts, securityHeaders, url, method);
	}

	const httpRequest = buildSmithyHttpRequest(signOpts, method);

	// awsGetSignInOptionsAndUpdateRequest always sets signOpts.service to the resolved
	// signing name; the raw first-hostname-label fallback is defensive only and does
	// not normalize (e.g. bedrock-runtime → bedrock).
	const service = signOpts.service ?? httpRequest.hostname.split('.')[0];
	const region = signOpts.region ?? 'us-east-1';

	// S3 needs aws4-equivalent treatment that other services must not get:
	// it requires the x-amz-content-sha256 header in the signature, and its
	// object keys must not be path-normalized or double-encoded. aws4 special-cased
	// S3 the same way; smithy's defaults (no checksum header, uriEscapePath: true)
	// match aws4 only for non-S3 services.
	const isS3 = service === 's3';

	const signer = new SignatureV4({
		credentials: {
			accessKeyId: securityHeaders.accessKeyId,
			secretAccessKey: securityHeaders.secretAccessKey,
			...(securityHeaders.sessionToken && { sessionToken: securityHeaders.sessionToken }),
		},
		region,
		service,
		sha256: Sha256,
		applyChecksum: isS3,
		uriEscapePath: !isS3,
	});

	// Let signing errors propagate. Falling back to the unsigned request only yields
	// an opaque 403 from AWS that hides the real cause.
	const signedRequest = (await signer.sign(httpRequest)) as HttpRequest;

	return {
		...requestOptions,
		headers: signedRequest.headers,
		method,
		url,
		body: signOpts.body,
		qs: undefined, // already encoded in url
	};
}
