/**
 * Query functions for EmDash content
 *
 * These wrap Astro's getLiveCollection/getLiveEntry with type filtering.
 * Use these instead of calling Astro's functions directly.
 *
 * Error handling follows Astro's pattern - returns { entries/entry, error }
 * so callers can gracefully handle errors (including 404s).
 *
 * Preview mode is handled implicitly via ALS request context —
 * no parameters needed. The middleware verifies the preview token
 * and sets the context; query functions read it automatically.
 */

import { encodeCursor } from "./database/repositories/types.js";
import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./i18n/config.js";
import { CURSOR_RAW_VALUES } from "./loader.js";
import { requestCached } from "./request-cache.js";
import { getRequestContext } from "./request-context.js";
import { isMissingTableError } from "./utils/db-errors.js";
import {
	createEditable,
	createNoop,
	type EditProxy,
	type EditableOptions,
} from "./visual-editing/editable.js";

/**
 * Collection type registry for type-safe queries.
 *
 * This interface is extended by the generated emdash-env.d.ts file
 * to provide type inference for collection names and their data shapes.
 *
 * @example
 * ```ts
 * // In emdash-env.d.ts (generated):
 * declare module "emdash" {
 *   interface EmDashCollections {
 *     posts: { title: string; content: PortableTextBlock[]; };
 *     pages: { title: string; body: PortableTextBlock[]; };
 *   }
 * }
 *
 * // Then in your code:
 * const { entries } = await getEmDashCollection("posts");
 * // entries[0].data.title is typed as string
 * ```
 */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface EmDashCollections {}

/**
 * Helper type to infer the data type for a collection.
 * Returns the registered type if known, otherwise falls back to Record<string, unknown>.
 */
export type InferCollectionData<T extends string> = T extends keyof EmDashCollections
	? EmDashCollections[T]
	: Record<string, unknown>;

/**
 * Sort direction
 */
export type SortDirection = "asc" | "desc";

/**
 * Order by specification - field name to direction
 * @example { created_at: "desc" } - Sort by created_at descending
 * @example { title: "asc" } - Sort by title ascending
 * @example { published_at: "desc", title: "asc" } - Multi-field sort
 */
export type OrderBySpec = Record<string, SortDirection>;

export interface CollectionFilter {
	status?: "draft" | "published" | "archived";
	limit?: number;
	/**
	 * Opaque cursor for keyset pagination.
	 * Pass the `nextCursor` value from a previous result to fetch the next page.
	 * @example
	 * ```ts
	 * const cursor = Astro.url.searchParams.get("cursor") ?? undefined;
	 * const { entries, nextCursor } = await getEmDashCollection("posts", {
	 *   limit: 10,
	 *   cursor,
	 * });
	 * ```
	 */
	cursor?: string;
	/**
	 * Filter by field values or taxonomy terms
	 * @example { category: 'news' } - Filter by taxonomy term
	 * @example { category: ['news', 'featured'] } - Filter by multiple terms (OR)
	 */
	where?: Record<string, string | string[]>;
	/**
	 * Order results by field(s)
	 * @default { created_at: "desc" }
	 * @example { created_at: "desc" } - Sort by created_at descending (default)
	 * @example { title: "asc" } - Sort by title ascending
	 * @example { published_at: "desc", title: "asc" } - Multi-field sort
	 */
	orderBy?: OrderBySpec;
	/**
	 * Filter by locale. When set, only returns entries in this locale.
	 * Only relevant when i18n is configured.
	 * @example "en" — English entries only
	 * @example "fr" — French entries only
	 */
	locale?: string;
}

export interface ContentEntry<T = Record<string, unknown>> {
	id: string;
	data: T;
	/** Visual editing annotations. Spread onto elements: {...entry.edit.title} */
	edit: EditProxy;
}

/** Cache hint returned by the content loader for route caching */
export interface CacheHint {
	tags?: string[];
	lastModified?: Date;
}

/**
 * Result from getEmDashCollection
 */
export interface CollectionResult<T> {
	/** The entries (empty array if error or none found) */
	entries: ContentEntry<T>[];
	/** Error if the query failed */
	error?: Error;
	/** Cache hint for route caching (pass to Astro.cache.set()) */
	cacheHint: CacheHint;
	/**
	 * Opaque cursor for the next page.
	 * Undefined when there are no more results.
	 * Pass this as `cursor` in the next query to get the next page.
	 */
	nextCursor?: string;
}

/**
 * Result from getEmDashEntry
 */
export interface EntryResult<T> {
	/** The entry, or null if not found */
	entry: ContentEntry<T> | null;
	/** Error if the query failed (not set for "not found", only for actual errors) */
	error?: Error;
	/** Whether we're in preview mode (valid token was provided) */
	isPreview: boolean;
	/** Set when a fallback locale was used instead of the requested locale */
	fallbackLocale?: string;
	/** Cache hint for route caching (pass to Astro.cache.set()) */
	cacheHint: CacheHint;
}

const COLLECTION_NAME = "_emdash";

/** Symbol key for edit metadata on PT arrays — avoids collision with user data */
const EMDASH_EDIT = Symbol.for("__emdash");

/** Edit metadata attached to PT arrays in edit mode */
export interface EditFieldMeta {
	collection: string;
	id: string;
	field: string;
}

/** Type guard for EditFieldMeta */
function isEditFieldMeta(value: unknown): value is EditFieldMeta {
	if (typeof value !== "object" || value === null) return false;
	if (!("collection" in value) || !("id" in value) || !("field" in value)) return false;
	// After `in` checks, TS narrows to Record<"collection" | "id" | "field", unknown>
	const { collection, id, field } = value;
	return typeof collection === "string" && typeof id === "string" && typeof field === "string";
}

/**
 * Read edit metadata from a value (returns undefined if not tagged).
 * Uses Object.getOwnPropertyDescriptor to access Symbol-keyed property
 * without an unsafe type assertion.
 */
export function getEditMeta(value: unknown): EditFieldMeta | undefined {
	if (value && typeof value === "object") {
		const desc = Object.getOwnPropertyDescriptor(value, EMDASH_EDIT);
		const meta: unknown = desc?.value;
		if (isEditFieldMeta(meta)) {
			return meta;
		}
	}
	return undefined;
}

/**
 * Tag PT-like arrays in entry data with edit metadata (non-enumerable).
 * A PT array is identified by: is an array, first element has _type property.
 */
function tagEditableFields(data: Record<string, unknown>, collection: string, id: string): void {
	for (const [field, value] of Object.entries(data)) {
		if (
			Array.isArray(value) &&
			value.length > 0 &&
			value[0] &&
			typeof value[0] === "object" &&
			"_type" in value[0]
		) {
			Object.defineProperty(value, EMDASH_EDIT, {
				value: { collection, id, field } satisfies EditFieldMeta,
				enumerable: false,
				configurable: true,
			});
		}
	}
}

/** Safely read a string field from a Record, with optional fallback */
function dataStr(data: Record<string, unknown>, key: string, fallback = ""): string {
	const val = data[key];
	return typeof val === "string" ? val : fallback;
}

/** Type guard for Record<string, unknown> */
function isRecord(value: unknown): value is Record<string, unknown> {
	return typeof value === "object" && value !== null && !Array.isArray(value);
}

/** Extract data as Record from an Astro entry (which is any-typed) */
function entryData(entry: { data?: unknown }): Record<string, unknown> {
	return isRecord(entry.data) ? entry.data : {};
}

/** Extract the database ID from entry data (data.id is the ULID, entry.id is the slug) */
function entryDatabaseId(entry: { id: string; data?: unknown }): string {
	const d = entryData(entry);
	return dataStr(d, "id") || entry.id;
}

/** Extract edit options from entry data for the proxy */
function entryEditOptions(entry: { data?: unknown }): EditableOptions {
	const data = entryData(entry);
	const status = dataStr(data, "status", "draft");
	const draftRevisionId = dataStr(data, "draftRevisionId") || undefined;
	const liveRevisionId = dataStr(data, "liveRevisionId") || undefined;
	const hasDraft = !!draftRevisionId && draftRevisionId !== liveRevisionId;
	return { status, hasDraft };
}

/**
 * Get all entries of a content type
 *
 * Returns { entries, error } for graceful error handling.
 *
 * When emdash-env.d.ts is generated, the collection name will be
 * type-checked and the return type will be inferred automatically.
 *
 * @example
 * ```ts
 * import { getEmDashCollection } from "emdash";
 *
 * const { entries: posts, error } = await getEmDashCollection("posts");
 * if (error) {
 *   console.error("Failed to load posts:", error);
 *   return;
 * }
 * // posts[0].data.title is typed (if emdash-env.d.ts exists)
 *
 * // With filters
 * const { entries: drafts } = await getEmDashCollection("posts", { status: "draft" });
 * ```
 */
export async function getEmDashCollection<T extends string, D = InferCollectionData<T>>(
	type: T,
	filter?: CollectionFilter,
): Promise<CollectionResult<D>> {
	// Cache per (type, filter) within a single request. Edit mode and
	// preview are request-scoped and stable, so they don't need to be
	// part of the key. Widgets and layouts frequently request the same
	// collection shape as the page itself (e.g. a "recent posts" list
	// appears on the home page AND in the sidebar) — caching collapses
	// those duplicate queries, along with the bylines and taxonomy-term
	// hydration each call would otherwise re-do.
	//
	// Bucket small limits to a shared minimum so a page with several
	// "recent N posts" widgets at slightly different limits (e.g. a
	// post-detail page asking for 4 in the body and 5 in the sidebar)
	// shares one fetch + hydration round-trip rather than running two.
	// Cursor-paginated calls are exempt: their limit is part of the
	// pagination contract.
	const bucketed = bucketFilter(filter);
	const cached = await requestCached(collectionCacheKey(type, bucketed.fetchFilter), () =>
		getEmDashCollectionUncached<T, D>(type, bucketed.fetchFilter),
	);
	return bucketed.requestedLimit === undefined
		? cached
		: sliceCollectionResult(cached, bucketed.requestedLimit, filter?.orderBy);
}

/**
 * Threshold for limit bucketing. Page templates routinely render small
 * "recent posts" widgets at limits 3-8; rounding those up to a single
 * shared bucket lets one fetch satisfy several widgets within a request.
 * Above this, the requested limit is honoured exactly — bucketing limit:50
 * to limit:64 would waste hydration work for callers fetching real pages.
 */
const BUCKET_LIMIT_THRESHOLD = 10;

interface BucketedFilter {
	/** Filter to pass to the loader (with limit possibly raised). */
	fetchFilter: CollectionFilter | undefined;
	/** Original limit; defined only when bucketing was applied. */
	requestedLimit: number | undefined;
}

/** @internal exported for unit tests; not part of the public API. */
export function bucketFilter(filter: CollectionFilter | undefined): BucketedFilter {
	const limit = filter?.limit;
	if (
		limit === undefined ||
		limit >= BUCKET_LIMIT_THRESHOLD ||
		limit <= 0 ||
		filter?.cursor !== undefined
	) {
		return { fetchFilter: filter, requestedLimit: undefined };
	}
	return {
		fetchFilter: { ...filter, limit: BUCKET_LIMIT_THRESHOLD },
		requestedLimit: limit,
	};
}

/**
 * Slice a cached bucketed result down to the originally-requested limit
 * and recompute `nextCursor` from the row that would have been the
 * over-fetch detector for that limit. When truncation is needed, returns
 * a shallow-copied result with a new `entries` array; otherwise returns
 * the cached result unchanged (including error results and results
 * already within the requested limit).
 */
/** @internal exported for unit tests; not part of the public API. */
export function sliceCollectionResult<D>(
	cached: CollectionResult<D>,
	limit: number,
	orderBy: OrderBySpec | undefined,
): CollectionResult<D> {
	if (cached.error) return cached;
	if (cached.entries.length <= limit) return cached;
	const sliced = cached.entries.slice(0, limit);
	// Mirror the loader's encoding: cursor points at the last returned row,
	// so "next page" picks up at the row immediately after it. See
	// buildCursorCondition in loader.ts — it filters strictly past this row.
	const lastEntry = sliced.at(-1);
	const nextCursor = lastEntry ? encodeEntryCursor(lastEntry, orderBy) : undefined;
	return { ...cached, entries: sliced, nextCursor };
}

/** Map of database column names to camelCase keys present on entry.data. */
const ENTRY_DATA_KEY_MAP: Record<string, string> = {
	created_at: "createdAt",
	updated_at: "updatedAt",
	published_at: "publishedAt",
	scheduled_at: "scheduledAt",
	author_id: "authorId",
	primary_byline_id: "primaryBylineId",
};

// Mirror loader.ts FIELD_NAME_PATTERN. Kept in sync intentionally — diverging
// would let the encoder accept a field name the loader's getPrimarySort then
// rejected, producing a cursor that paginates against a different column.
const FIELD_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;

/**
 * Encode a `nextCursor` from a content entry, mirroring the loader's
 * encoding scheme: `(orderValue, id)` where `orderValue` is the primary
 * sort field's stringified value. For date columns, reads the raw DB
 * string the loader stashed via CURSOR_RAW_VALUES — round-tripping the
 * parsed Date through `toISOString()` would lose precision for stored
 * values that aren't already ISO-with-milliseconds.
 */
function encodeEntryCursor<D>(
	entry: ContentEntry<D>,
	orderBy: OrderBySpec | undefined,
): string | undefined {
	const data = entryData(entry);
	const id = dataStr(data, "id");
	if (!id) return undefined;

	// Match loader.ts getPrimarySort: take the first valid field, default to created_at.
	let dbField = "created_at";
	if (orderBy) {
		for (const field of Object.keys(orderBy)) {
			if (FIELD_NAME_PATTERN.test(field)) {
				dbField = field;
				break;
			}
		}
	}

	// Date columns: prefer the raw stored string captured by the loader so
	// the cursor matches what a direct loader fetch would emit, regardless
	// of how the DB stored the timestamp.
	const rawDateValuesRaw = Reflect.get(data, CURSOR_RAW_VALUES);
	if (rawDateValuesRaw !== null && typeof rawDateValuesRaw === "object") {
		const raw = Reflect.get(rawDateValuesRaw, dbField);
		if (typeof raw === "string") return encodeCursor(raw, id);
	}

	const dataKey = ENTRY_DATA_KEY_MAP[dbField] ?? dbField;
	const value = data[dataKey];
	let orderValue: string;
	if (value instanceof Date) {
		orderValue = value.toISOString();
	} else if (typeof value === "string" || typeof value === "number") {
		orderValue = String(value);
	} else {
		// Match the loader's empty-string fallback for null/undefined order
		// values so cursor decoding stays valid even at the boundary.
		orderValue = "";
	}
	return encodeCursor(orderValue, id);
}

/**
 * Build a canonical cache key for `getEmDashCollection`.
 *
 * `JSON.stringify` is insertion-order-sensitive, so two callers passing
 * semantically identical filters with different key orders would miss
 * the cache. We fix the top-level field order and sort `where` keys
 * (order there is irrelevant), while preserving `orderBy` key order
 * because that's the sort priority.
 */
function collectionCacheKey(type: string, filter?: CollectionFilter): string {
	if (!filter) return `collection:${type}:`;
	const parts = [
		filter.status ?? "",
		filter.limit ?? "",
		filter.cursor ?? "",
		filter.where ? stableStringify(filter.where) : "",
		filter.orderBy ? JSON.stringify(filter.orderBy) : "",
		filter.locale ?? "",
	];
	return `collection:${type}:${parts.join("|")}`;
}

function stableStringify(value: Record<string, unknown>): string {
	const keys = Object.keys(value).toSorted();
	const ordered: Record<string, unknown> = {};
	for (const k of keys) ordered[k] = value[k];
	return JSON.stringify(ordered);
}

async function getEmDashCollectionUncached<T extends string, D = InferCollectionData<T>>(
	type: T,
	filter?: CollectionFilter,
): Promise<CollectionResult<D>> {
	// Dynamic import to avoid build-time issues
	const { getLiveCollection } = await import("astro:content");

	// Resolve locale: explicit filter > ALS context > defaultLocale (when i18n enabled)
	// Without this, queries return all locale rows, producing broken IDs
	const ctx = getRequestContext();
	const i18nConfig = getI18nConfig();
	const resolvedLocale =
		filter?.locale ?? ctx?.locale ?? (isI18nEnabled() ? i18nConfig!.defaultLocale : undefined);

	const result = await getLiveCollection(COLLECTION_NAME, {
		type,
		status: filter?.status,
		limit: filter?.limit,
		cursor: filter?.cursor,
		where: filter?.where,
		orderBy: filter?.orderBy,
		locale: resolvedLocale,
	});

	const { entries, error, cacheHint } = result;
	// nextCursor is returned by the emdash loader but not part of Astro's base
	// LiveLoader return type. Extract it safely via property descriptor to avoid
	// an unsafe type assertion on the `any`-typed result object.
	const rawCursor = Object.getOwnPropertyDescriptor(result, "nextCursor")?.value;
	const nextCursor: string | undefined = typeof rawCursor === "string" ? rawCursor : undefined;

	if (error) {
		return { entries: [], error, cacheHint: {} };
	}

	const isEditMode = ctx?.editMode ?? false;
	const entriesWithEdit = entries.map((entry: ContentEntry<D>) => {
		const dbId = entryDatabaseId(entry);
		if (isEditMode) {
			tagEditableFields(entryData(entry), type, dbId);
		}
		return {
			...entry,
			edit: isEditMode ? createEditable(type, dbId, entryEditOptions(entry)) : createNoop(),
		};
	});

	// Eagerly hydrate bylines and taxonomy terms for all entries in parallel.
	// Both are independent queries, so running them concurrently halves the
	// round-trip cost on remote databases (D1 replicas, etc.).
	await Promise.all([
		hydrateEntryBylines(type, entriesWithEdit),
		hydrateEntryTerms(type, entriesWithEdit),
	]);

	return { entries: entriesWithEdit, nextCursor, cacheHint: cacheHint ?? {} };
}

/**
 * Get a single entry by type and ID/slug
 *
 * Returns { entry, error, isPreview } for graceful error handling.
 * - entry is null if not found (not an error)
 * - error is set only for actual errors (db issues, etc.)
 *
 * Preview mode is detected automatically from request context (ALS).
 * When the URL has a valid `_preview` token, the middleware sets preview
 * context and this function serves draft revision data if available.
 *
 * @example
 * ```ts
 * import { getEmDashEntry } from "emdash";
 *
 * // Simple usage — preview just works via middleware
 * const { entry: post, isPreview, error } = await getEmDashEntry("posts", "my-slug");
 * if (!post) return Astro.redirect("/404");
 * ```
 */
export async function getEmDashEntry<T extends string, D = InferCollectionData<T>>(
	type: T,
	id: string,
	options?: { locale?: string },
): Promise<EntryResult<D>> {
	// Dynamic import to avoid build-time issues
	const { getLiveEntry } = await import("astro:content");

	// Check ALS for preview and edit mode context
	const ctx = getRequestContext();
	const preview = ctx?.preview;
	const isEditMode = ctx?.editMode ?? false;
	const isPreviewMode = !!preview && preview.collection === type;
	// Edit mode implies preview — editors should see draft content
	const serveDrafts = isPreviewMode || isEditMode;

	// Resolve locale: explicit option > ALS context > undefined (no filter)
	const requestedLocale = options?.locale ?? ctx?.locale;

	/** Wrap a raw Astro entry with edit proxy, tagging editable fields if needed */
	function wrapEntry(raw: ContentEntry<D>): ContentEntry<D> {
		const dbId = entryDatabaseId(raw);
		if (isEditMode) {
			tagEditableFields(entryData(raw), type, dbId);
		}
		return {
			...raw,
			edit: isEditMode ? createEditable(type, dbId, entryEditOptions(raw)) : createNoop(),
		};
	}

	/** Check if an entry is publicly visible (published or scheduled past its time) */
	function isVisible(entry: ContentEntry<D>): boolean {
		const data = entryData(entry);
		const status = dataStr(data, "status");
		const scheduledAt = dataStr(data, "scheduledAt") || undefined;
		const isPublished = status === "published";
		const isScheduledAndReady =
			status === "scheduled" && scheduledAt && new Date(scheduledAt) <= new Date();
		return isPublished || !!isScheduledAndReady;
	}

	// Build the fallback chain: [requestedLocale, fallback1, ..., defaultLocale]
	// When i18n is disabled or no locale requested, just use a single-element chain
	const localeChain =
		requestedLocale && isI18nEnabled() ? getFallbackChain(requestedLocale) : [requestedLocale];

	/** Return a successful EntryResult with bylines and taxonomy terms hydrated */
	async function successResult(
		wrapped: ContentEntry<D>,
		opts: { isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint },
	): Promise<EntryResult<D>> {
		await Promise.all([hydrateEntryBylines(type, [wrapped]), hydrateEntryTerms(type, [wrapped])]);
		return {
			entry: wrapped,
			isPreview: opts.isPreview,
			fallbackLocale: opts.fallbackLocale,
			cacheHint: opts.cacheHint,
		};
	}

	if (serveDrafts) {
		// Draft mode: try each locale in the fallback chain
		for (let i = 0; i < localeChain.length; i++) {
			const locale = localeChain[i];
			const fallbackLocale = i > 0 ? locale : undefined;

			const {
				entry: baseEntry,
				error: baseError,
				cacheHint,
			} = await getLiveEntry(COLLECTION_NAME, {
				type,
				id,
				locale,
			});

			if (baseError) {
				return { entry: null, error: baseError, isPreview: serveDrafts, cacheHint: {} };
			}

			if (!baseEntry) continue; // Try next locale in chain

			// Preview tokens are item-scoped: verify the resolved entry matches.
			// Edit mode (authenticated editors) has collection-wide draft access.
			if (isPreviewMode && !isEditMode) {
				const dbId = entryDatabaseId(baseEntry);
				if (preview.id !== dbId && preview.id !== id) {
					// Token doesn't match — serve only if publicly visible, without draft access
					if (isVisible(baseEntry)) {
						return successResult(wrapEntry(baseEntry), {
							isPreview: false,
							fallbackLocale,
							cacheHint: cacheHint ?? {},
						});
					}
					// Not visible — try next locale in fallback chain
					continue;
				}
			}

			// Check if entry has a draft revision — if so, re-fetch with revision data
			const baseData = entryData(baseEntry);
			const draftRevisionId = dataStr(baseData, "draftRevisionId") || undefined;

			if (draftRevisionId) {
				const { entry: draftEntry, error: draftError } = await getLiveEntry(COLLECTION_NAME, {
					type,
					id,
					revisionId: draftRevisionId,
					locale,
				});

				if (!draftError && draftEntry) {
					return successResult(wrapEntry(draftEntry), {
						isPreview: serveDrafts,
						fallbackLocale,
						cacheHint: cacheHint ?? {},
					});
				}
			}

			return successResult(wrapEntry(baseEntry), {
				isPreview: serveDrafts,
				fallbackLocale,
				cacheHint: cacheHint ?? {},
			});
		}

		// No entry found in any locale
		return { entry: null, isPreview: serveDrafts, cacheHint: {} };
	}

	// Normal mode: try each locale in the fallback chain, only return published content
	for (let i = 0; i < localeChain.length; i++) {
		const locale = localeChain[i];
		const fallbackLocale = i > 0 ? locale : undefined;

		const { entry, error, cacheHint } = await getLiveEntry(COLLECTION_NAME, { type, id, locale });
		if (error) {
			return { entry: null, error, isPreview: false, cacheHint: {} };
		}

		if (entry && isVisible(entry)) {
			return successResult(wrapEntry(entry), {
				isPreview: false,
				fallbackLocale,
				cacheHint: cacheHint ?? {},
			});
		}
		// Entry not found or not visible in this locale — try next
	}

	return { entry: null, isPreview: false, cacheHint: {} };
}

/**
 * Eagerly hydrate byline data onto entry.data for one or more entries.
 *
 * Attaches `bylines` (array of ContentBylineCredit) and `byline`
 * (primary BylineSummary or null) to each entry's data object.
 * Uses batch queries to avoid N+1.
 *
 * Fails silently if the byline tables don't exist yet (pre-migration).
 */
async function hydrateEntryBylines<D>(type: string, entries: ContentEntry<D>[]): Promise<void> {
	if (entries.length === 0) return;

	try {
		const { getBylinesForEntries } = await import("./bylines/index.js");

		const refs = entries
			.map((e) => {
				const data = entryData(e);
				const id = dataStr(data, "id");
				return id ? { id, authorId: dataStr(data, "authorId") || null } : null;
			})
			.filter((r): r is { id: string; authorId: string | null } => r !== null);
		if (refs.length === 0) return;

		const bylinesMap = await getBylinesForEntries(type, refs);

		for (const entry of entries) {
			const data = entryData(entry);
			const dbId = dataStr(data, "id");
			if (!dbId) continue;

			const credits = bylinesMap.get(dbId) ?? [];
			data.bylines = credits;
			data.byline = credits[0]?.byline ?? null;
		}
	} catch (err) {
		// Only swallow "table not found" errors from pre-migration databases.
		// Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table
		// ... does not exist") via the shared helper.
		if (!isMissingTableError(err)) {
			const msg = err instanceof Error ? err.message : String(err);
			console.warn("[emdash] Failed to hydrate bylines:", msg);
		}
	}
}

/**
 * Eagerly hydrate taxonomy term data onto entry.data for one or more entries.
 *
 * Attaches `terms` (Record keyed by taxonomy name with an array of TaxonomyTerm
 * values) to each entry's data object. Uses a single batched JOIN query across
 * all taxonomies so the cost is O(1) regardless of the number of entries or
 * taxonomies on the site.
 *
 * This eliminates the common N+1 pattern where templates loop over list
 * results and call getEntryTerms() per entry. With hydration, the list page
 * stays at a single round-trip for term data.
 *
 * Fails silently if the taxonomy tables don't exist yet (pre-migration).
 */
async function hydrateEntryTerms<D>(type: string, entries: ContentEntry<D>[]): Promise<void> {
	if (entries.length === 0) return;

	try {
		const { getAllTermsForEntries } = await import("./taxonomies/index.js");

		const ids = entries.map((e) => dataStr(entryData(e), "id")).filter(Boolean);
		if (ids.length === 0) return;

		const termsMap = await getAllTermsForEntries(type, ids);

		for (const entry of entries) {
			const data = entryData(entry);
			const dbId = dataStr(data, "id");
			if (!dbId) continue;

			data.terms = termsMap.get(dbId) ?? {};
		}
	} catch (err) {
		// Only swallow "table not found" errors from pre-migration databases.
		// Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table
		// ... does not exist") via the shared helper.
		if (!isMissingTableError(err)) {
			const msg = err instanceof Error ? err.message : String(err);
			console.warn("[emdash] Failed to hydrate terms:", msg);
		}
	}
}

/**
 * Translation summary for a single locale variant
 */
export interface TranslationSummary {
	/** Content item ID */
	id: string;
	/** Locale code (e.g. "en", "fr") */
	locale: string;
	/** URL slug */
	slug: string | null;
	/** Current status */
	status: string;
}

/**
 * Result from getTranslations
 */
export interface TranslationsResult {
	/** The translation group ID (shared across locales) */
	translationGroup: string;
	/** All locale variants in this group */
	translations: TranslationSummary[];
	/** Error if the query failed */
	error?: Error;
}

/**
 * Get all translations of a content item.
 *
 * Given a content entry, returns all locale variants that share the same
 * translation group. This is useful for building language switcher UI.
 *
 * @example
 * ```ts
 * import { getEmDashEntry, getTranslations } from "emdash";
 *
 * const { entry: post } = await getEmDashEntry("posts", "hello-world", { locale: "en" });
 * const { translations } = await getTranslations("posts", post.data.id);
 * // translations = [{ id: "...", locale: "en", slug: "hello-world", status: "published" }, ...]
 * ```
 */
export async function getTranslations(type: string, id: string): Promise<TranslationsResult> {
	try {
		const db = (await import("./loader.js")).getDb;
		const dbInstance = await db();
		const { ContentRepository } = await import("./database/repositories/content.js");
		const repo = new ContentRepository(dbInstance);

		// Find the item to get its translation group
		const item = await repo.findByIdOrSlug(type, id);
		if (!item) {
			return {
				translationGroup: "",
				translations: [],
				error: new Error(`Content item not found: ${id}`),
			};
		}

		const group = item.translationGroup || item.id;
		const translations = await repo.findTranslations(type, group);

		return {
			translationGroup: group,
			translations: translations.map((t) => ({
				id: t.id,
				locale: t.locale || "en",
				slug: t.slug,
				status: t.status,
			})),
		};
	} catch (error) {
		return {
			translationGroup: "",
			translations: [],
			error: error instanceof Error ? error : new Error(String(error)),
		};
	}
}

/**
 * Result from resolveEmDashPath
 */
export interface ResolvePathResult<T = Record<string, unknown>> {
	/** The matched entry */
	entry: ContentEntry<T>;
	/** The collection slug that matched */
	collection: string;
	/** Extracted parameters from the URL pattern (e.g. { slug: "my-post" }) */
	params: Record<string, string>;
}

/** Matches `{paramName}` placeholders in URL patterns */
const URL_PARAM_PATTERN = /\{(\w+)\}/g;

/** Convert a URL pattern like "/blog/{slug}" to a regex and param name list */
function patternToRegex(pattern: string): { regex: RegExp; paramNames: string[] } {
	const paramNames: string[] = [];
	const regexStr = pattern.replace(URL_PARAM_PATTERN, (_match, name: string) => {
		paramNames.push(name);
		return "([^/]+)";
	});
	return { regex: new RegExp(`^${regexStr}$`), paramNames };
}

/** Cached compiled URL patterns for resolveEmDashPath */
interface CachedPattern {
	slug: string;
	regex: RegExp;
	paramNames: string[];
}
let cachedUrlPatterns: CachedPattern[] | null = null;

/**
 * Invalidate the cached URL patterns used by resolveEmDashPath.
 * Call when collection URL patterns change (schema updates).
 */
export function invalidateUrlPatternCache(): void {
	cachedUrlPatterns = null;
}

/**
 * Resolve a URL path to a content entry by matching against collection URL patterns.
 *
 * Loads all collections with a `urlPattern` set, converts each pattern to a regex,
 * and tests the given path. On match, extracts the slug and fetches the entry.
 *
 * @example
 * ```ts
 * import { resolveEmDashPath } from "emdash";
 *
 * // Given pages with urlPattern "/{slug}" and posts with "/blog/{slug}":
 * const result = await resolveEmDashPath("/blog/hello-world");
 * if (result) {
 *   console.log(result.collection); // "posts"
 *   console.log(result.params.slug); // "hello-world"
 *   console.log(result.entry.data); // post data
 * }
 * ```
 */
export async function resolveEmDashPath<T = Record<string, unknown>>(
	path: string,
): Promise<ResolvePathResult<T> | null> {
	// Build and cache compiled patterns on first call
	if (!cachedUrlPatterns) {
		const { getDb } = await import("./loader.js");
		const { SchemaRegistry } = await import("./schema/registry.js");
		const db = await getDb();
		const registry = new SchemaRegistry(db);
		const collections = await registry.listCollections();

		cachedUrlPatterns = [];
		for (const collection of collections) {
			if (!collection.urlPattern) continue;
			const { regex, paramNames } = patternToRegex(collection.urlPattern);
			cachedUrlPatterns.push({ slug: collection.slug, regex, paramNames });
		}
	}

	for (const pattern of cachedUrlPatterns) {
		const match = path.match(pattern.regex);
		if (!match) continue;

		// Extract params
		const params: Record<string, string> = {};
		for (let i = 0; i < pattern.paramNames.length; i++) {
			params[pattern.paramNames[i]] = match[i + 1];
		}

		// Look up entry by slug (most common pattern)
		const slug = params.slug;
		if (!slug) continue;

		const { entry } = await getEmDashEntry<string, T>(pattern.slug, slug);
		if (entry) {
			return { entry, collection: pattern.slug, params };
		}
	}

	return null;
}
