import type {
	ComponentInstance,
	Locales,
	Params,
	Props,
	RouteData,
	SSRElement,
	SSRResult,
} from '../../@types/astro.js';
import { normalizeTheLocale, toCodes } from '../../i18n/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import type { Environment } from './environment.js';
import { getParamsAndProps } from './params-and-props.js';
import type { RoutingStrategies } from '../config/schema.js';
import { ROUTE_DATA_SYMBOL } from '../constants.js';

const clientLocalsSymbol = Symbol.for('astro.locals');
const routeDataSymbol = Symbol.for(ROUTE_DATA_SYMBOL);

/**
 * The RenderContext represents the parts of rendering that are specific to one request.
 */
export interface RenderContext {
	request: Request;
	pathname: string;
	scripts?: Set<SSRElement>;
	links?: Set<SSRElement>;
	styles?: Set<SSRElement>;
	componentMetadata?: SSRResult['componentMetadata'];
	route: RouteData;
	status?: number;
	params: Params;
	props: Props;
	locals?: object;
	locales: Locales | undefined;
	defaultLocale: string | undefined;
	routing: RoutingStrategies | undefined;
}

export type CreateRenderContextArgs = Partial<
	Omit<RenderContext, 'params' | 'props' | 'locals'>
> & {
	route: RouteData;
	request: RenderContext['request'];
	mod: ComponentInstance | undefined;
	env: Environment;
};

export async function createRenderContext(
	options: CreateRenderContextArgs
): Promise<RenderContext> {
	const request = options.request;
	const pathname = options.pathname ?? new URL(request.url).pathname;
	const [params, props] = await getParamsAndProps({
		mod: options.mod as any,
		route: options.route,
		routeCache: options.env.routeCache,
		pathname: pathname,
		logger: options.env.logger,
		ssr: options.env.ssr,
	});

	const context: RenderContext = {
		...options,
		pathname,
		params,
		props,
		locales: options.locales,
		routing: options.routing,
		defaultLocale: options.defaultLocale,
	};

	// We define a custom property, so we can check the value passed to locals
	Object.defineProperty(context, 'locals', {
		enumerable: true,
		get() {
			return Reflect.get(request, clientLocalsSymbol);
		},
		set(val) {
			if (typeof val !== 'object') {
				throw new AstroError(AstroErrorData.LocalsNotAnObject);
			} else {
				Reflect.set(request, clientLocalsSymbol, val);
			}
		},
	});

	return context;
}

type BrowserLocale = {
	locale: string;
	qualityValue: number | undefined;
};

/**
 * Parses the value of the `Accept-Header` language:
 *
 * More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language
 *
 * Complex example: `fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5`
 *
 */
export function parseLocale(header: string): BrowserLocale[] {
	// Any language, early return
	if (header === '*') {
		return [{ locale: header, qualityValue: undefined }];
	}
	const result: BrowserLocale[] = [];
	// we split by `,` and trim the white spaces
	const localeValues = header.split(',').map((str) => str.trim());

	for (const localeValue of localeValues) {
		// split the locale name from the quality value
		const split = localeValue.split(';').map((str) => str.trim());
		const localeName: string = split[0];
		const qualityValue: string | undefined = split[1];

		if (!split) {
			// invalid value
			continue;
		}

		// we check if the quality value is present, and it is actually `q=`
		if (qualityValue && qualityValue.startsWith('q=')) {
			const qualityValueAsFloat = Number.parseFloat(qualityValue.slice('q='.length));
			// The previous operation can return a `NaN`, so we check if it is a safe operation
			if (Number.isNaN(qualityValueAsFloat) || qualityValueAsFloat > 1) {
				result.push({
					locale: localeName,
					qualityValue: undefined,
				});
			} else {
				result.push({
					locale: localeName,
					qualityValue: qualityValueAsFloat,
				});
			}
		} else {
			result.push({
				locale: localeName,
				qualityValue: undefined,
			});
		}
	}

	return result;
}

function sortAndFilterLocales(browserLocaleList: BrowserLocale[], locales: Locales) {
	const normalizedLocales = toCodes(locales).map(normalizeTheLocale);
	return browserLocaleList
		.filter((browserLocale) => {
			if (browserLocale.locale !== '*') {
				return normalizedLocales.includes(normalizeTheLocale(browserLocale.locale));
			}
			return true;
		})
		.sort((a, b) => {
			if (a.qualityValue && b.qualityValue) {
				if (a.qualityValue > b.qualityValue) {
					return -1;
				} else if (a.qualityValue < b.qualityValue) {
					return 1;
				}
			}
			return 0;
		});
}

/**
 * Set the current locale by parsing the value passed from the `Accept-Header`.
 *
 * If multiple locales are present in the header, they are sorted by their quality value and the highest is selected as current locale.
 *
 */
export function computePreferredLocale(request: Request, locales: Locales): string | undefined {
	const acceptHeader = request.headers.get('Accept-Language');
	let result: string | undefined = undefined;
	if (acceptHeader) {
		const browserLocaleList = sortAndFilterLocales(parseLocale(acceptHeader), locales);

		const firstResult = browserLocaleList.at(0);
		if (firstResult && firstResult.locale !== '*') {
			for (const currentLocale of locales) {
				if (typeof currentLocale === 'string') {
					if (normalizeTheLocale(currentLocale) === normalizeTheLocale(firstResult.locale)) {
						result = currentLocale;
					}
				} else {
					for (const currentCode of currentLocale.codes) {
						if (normalizeTheLocale(currentCode) === normalizeTheLocale(firstResult.locale)) {
							result = currentLocale.path;
						}
					}
				}
			}
		}
	}

	return result;
}

export function computePreferredLocaleList(request: Request, locales: Locales): string[] {
	const acceptHeader = request.headers.get('Accept-Language');
	let result: string[] = [];
	if (acceptHeader) {
		const browserLocaleList = sortAndFilterLocales(parseLocale(acceptHeader), locales);

		// SAFETY: bang operator is safe because checked by the previous condition
		if (browserLocaleList.length === 1 && browserLocaleList.at(0)!.locale === '*') {
			return locales.map((locale) => {
				if (typeof locale === 'string') {
					return locale;
				} else {
					// SAFETY: codes is never empty
					return locale.codes.at(0)!;
				}
			});
		} else if (browserLocaleList.length > 0) {
			for (const browserLocale of browserLocaleList) {
				for (const loopLocale of locales) {
					if (typeof loopLocale === 'string') {
						if (normalizeTheLocale(loopLocale) === normalizeTheLocale(browserLocale.locale)) {
							result.push(loopLocale);
						}
					} else {
						for (const code of loopLocale.codes) {
							if (code === browserLocale.locale) {
								result.push(loopLocale.path);
							}
						}
					}
				}
			}
		}
	}

	return result;
}

export function computeCurrentLocale(
	request: Request,
	locales: Locales,
	routingStrategy: RoutingStrategies | undefined,
	defaultLocale: string | undefined
): undefined | string {
	const routeData: RouteData | undefined = Reflect.get(request, routeDataSymbol);
	if (!routeData) {
		return defaultLocale;
	}
	// Typically, RouteData::pathname has the correct information in SSR, but it's not available in SSG, so we fall back
	// to use the pathname from the Request
	const pathname = routeData.pathname ?? new URL(request.url).pathname;
	for (const segment of pathname.split('/').filter(Boolean)) {
		for (const locale of locales) {
			if (typeof locale === 'string') {
				// we skip ta locale that isn't present in the current segment

				if (!segment.includes(locale)) continue;
				if (normalizeTheLocale(locale) === normalizeTheLocale(segment)) {
					return locale;
				}
			} else {
				if (locale.path === segment) {
					return locale.codes.at(0);
				} else {
					for (const code of locale.codes) {
						if (normalizeTheLocale(code) === normalizeTheLocale(segment)) {
							return code;
						}
					}
				}
			}
		}
	}

	if (
		routingStrategy === 'pathname-prefix-other-locales' ||
		routingStrategy === 'domains-prefix-other-locales'
	) {
		return defaultLocale;
	}
	return undefined;
}
