import { z } from 'astro/zod';
import { type ContentConfig, type ImageFunction, type SchemaContext } from 'astro:content';
import project from 'virtual:starlight/project-context';
import config from 'virtual:starlight/user-config';
import { getCollectionPathFromRoot } from './collection';
import { parseWithFriendlyErrors, parseAsyncWithFriendlyErrors } from './error-map';
import { stripLeadingAndTrailingSlashes } from './path';
import {
	getSiteTitle,
	getSiteTitleHref,
	getToC,
	type PageProps,
	type RouteDataContext,
} from './routing/data';
import type { StarlightDocsEntry, StarlightRouteData } from './routing/types';
import { slugToLocaleData, urlToSlug } from './slugs';
import { getPrevNextLinks, getSidebar, getSidebarFromConfig } from './navigation';
import { docsSchema } from '../schema';
import type { Prettify, RemoveIndexSignature } from './types';
import { SidebarItemSchema } from '../schemas/sidebar';
import type { StarlightConfig, StarlightUserConfig } from './user-config';
import { getHead } from './head';

/**
 * The frontmatter schema for Starlight pages derived from the default schema for Starlight’s
 * `docs` content collection.
 * The frontmatter schema for Starlight pages cannot include some properties which will be omitted
 * and some others needs to be refined to a stricter type.
 */
const StarlightPageFrontmatterSchema = async (context: SchemaContext) => {
	const userDocsSchema = await getUserDocsSchema();
	const schema = typeof userDocsSchema === 'function' ? userDocsSchema(context) : userDocsSchema;

	return schema.transform((frontmatter) => {
		/**
		 * Starlight pages can only be edited if an edit URL is explicitly provided.
		 * The `sidebar` frontmatter prop only works for pages in an autogenerated links group.
		 * Starlight pages edit links cannot be autogenerated.
		 *
		 * These changes to the schema are done using a transformer and not using the usual `omit`
		 * method because when the frontmatter schema is extended by the user, an intersection between
		 * the default schema and the user schema is created using the `and` method. Intersections in
		 * Zod returns a `ZodIntersection` object which does not have some methods like `omit` or
		 * `pick`.
		 *
		 * This transformer only sets the `editUrl` default value and removes the `sidebar` property
		 * from the validated output but does not appply any changes to the input schema type itself so
		 * this needs to be done manually.
		 *
		 * @see StarlightPageFrontmatter
		 * @see https://github.com/colinhacks/zod#intersections
		 */
		const { editUrl, sidebar, ...others } = frontmatter;
		const pageEditUrl = editUrl === undefined || editUrl === true ? false : editUrl;
		return { ...others, editUrl: pageEditUrl };
	});
};

/**
 * Type of Starlight pages frontmatter schema.
 * We manually refines the `editUrl` type and omit the `sidebar` property as it's not possible to
 * do that on the schema itself using Zod but the proper validation is still using a transformer.
 * @see StarlightPageFrontmatterSchema
 */
type StarlightPageFrontmatter = Omit<
	z.input<Awaited<ReturnType<typeof StarlightPageFrontmatterSchema>>>,
	'editUrl' | 'sidebar'
> & { editUrl?: string | false };

/** Parse sidebar prop to ensure it's valid. */
const validateSidebarProp = (
	sidebarProp: StarlightUserConfig['sidebar']
): StarlightConfig['sidebar'] => {
	return parseWithFriendlyErrors(
		SidebarItemSchema.array().optional(),
		sidebarProp,
		'Invalid sidebar prop passed to the `<StarlightPage/>` component.'
	);
};

/**
 * The props accepted by the `<StarlightPage/>` component.
 */
export type StarlightPageProps = Prettify<
	// Remove the index signature from `Route`, omit undesired properties and make the rest optional.
	Partial<Omit<RemoveIndexSignature<PageProps>, 'entry' | 'entryMeta' | 'id' | 'locale' | 'slug'>> &
		// Add the sidebar definitions for a Starlight page.
		Partial<Pick<StarlightRouteData, 'hasSidebar'>> & {
			sidebar?: StarlightUserConfig['sidebar'];
			// And finally add the Starlight page frontmatter properties in a `frontmatter` property.
			frontmatter: StarlightPageFrontmatter;
		}
>;

/**
 * A docs entry used for Starlight pages meant to be rendered by plugins and which is safe to cast
 * to a `StarlightDocsEntry`.
 * A Starlight page docs entry cannot be rendered like a content collection entry.
 */
type StarlightPageDocsEntry = Omit<StarlightDocsEntry, 'id' | 'render'> & {
	/**
	 * The unique ID if using the `legacy.collections` for this Starlight page which cannot be
	 * inferred from codegen like content collection entries or the slug.
	 */
	id: string;
};

export async function generateStarlightPageRouteData({
	props,
	context,
}: {
	props: StarlightPageProps;
	context: RouteDataContext;
}): Promise<StarlightRouteData> {
	const { frontmatter, ...routeProps } = props;
	const { url } = context;
	const slug = urlToSlug(url);
	const pageFrontmatter = await getStarlightPageFrontmatter(frontmatter);
	const id = project.legacyCollections ? `${stripLeadingAndTrailingSlashes(slug)}.md` : slug;
	const localeData = slugToLocaleData(slug);
	const sidebar = props.sidebar
		? getSidebarFromConfig(validateSidebarProp(props.sidebar), url.pathname, localeData.locale)
		: getSidebar(url.pathname, localeData.locale);
	const headings = props.headings ?? [];
	const pageDocsEntry: StarlightPageDocsEntry = {
		id,
		slug,
		body: '',
		collection: 'docs',
		filePath: `${getCollectionPathFromRoot('docs', project)}/${stripLeadingAndTrailingSlashes(slug)}.md`,
		data: {
			...pageFrontmatter,
			sidebar: {
				attrs: {},
				hidden: false,
			},
		},
	};
	const entry = pageDocsEntry as StarlightDocsEntry;
	const entryMeta: StarlightRouteData['entryMeta'] = {
		dir: props.dir ?? localeData.dir,
		lang: props.lang ?? localeData.lang,
		locale: localeData.locale,
	};
	const editUrl = pageFrontmatter.editUrl ? new URL(pageFrontmatter.editUrl) : undefined;
	const lastUpdated =
		pageFrontmatter.lastUpdated instanceof Date ? pageFrontmatter.lastUpdated : undefined;
	const pageProps: PageProps = {
		...routeProps,
		...localeData,
		entry,
		entryMeta,
		headings,
		id,
		locale: localeData.locale,
		slug,
	};
	const siteTitle = getSiteTitle(localeData.lang);
	const routeData: StarlightRouteData = {
		...routeProps,
		...localeData,
		id,
		editUrl,
		entry,
		entryMeta,
		hasSidebar: props.hasSidebar ?? entry.data.template !== 'splash',
		head: getHead(pageProps, context, siteTitle),
		headings,
		lastUpdated,
		pagination: getPrevNextLinks(sidebar, config.pagination, entry.data),
		sidebar,
		siteTitle,
		siteTitleHref: getSiteTitleHref(localeData.locale),
		slug,
		toc: getToC(pageProps),
	};
	return routeData;
}

/** Validates the Starlight page frontmatter properties from the props received by a Starlight page. */
async function getStarlightPageFrontmatter(frontmatter: StarlightPageFrontmatter) {
	const schema = await StarlightPageFrontmatterSchema({
		image: (() =>
			// Mock validator for ImageMetadata.
			// https://github.com/withastro/astro/blob/cf993bc263b58502096f00d383266cd179f331af/packages/astro/src/assets/types.ts#L32
			// It uses a custom validation approach because imported SVGs have a type of `function` as
			// well as containing the metadata properties and this ensures we handle those correctly.
			z.custom(
				(value) =>
					value &&
					(typeof value === 'function' || typeof value === 'object') &&
					'src' in value &&
					'width' in value &&
					'height' in value &&
					'format' in value,
				'Invalid image passed to `<StarlightPage>` component. Expected imported `ImageMetadata` object.'
			)) as ImageFunction,
	});

	// Starting with Astro 4.14.0, a frontmatter schema that contains collection references will
	// contain an async transform.
	return parseAsyncWithFriendlyErrors(
		schema,
		frontmatter,
		'Invalid frontmatter props passed to the `<StarlightPage/>` component.'
	);
}

/** Returns the user docs schema and falls back to the default schema if needed. */
async function getUserDocsSchema(): Promise<
	NonNullable<ContentConfig['collections']['docs']['schema']>
> {
	const userCollections = (await import('virtual:starlight/collection-config')).collections;
	return userCollections?.docs?.schema ?? docsSchema();
}
