import {
	Badge,
	Button,
	Checkbox,
	Dialog,
	Input,
	InputArea,
	Label,
	LinkButton,
	Loader,
	Select,
	Switch,
} from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import {
	Check,
	Eye,
	MagnifyingGlass,
	Paperclip,
	X,
	Trash,
	ArrowsInSimple,
	ArrowsOutSimple,
	ArrowSquareOut,
} from "@phosphor-icons/react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import type { Editor } from "@tiptap/react";
import * as React from "react";

import type {
	BylineCreditInput,
	BylineSummary,
	ContentItem,
	MediaItem,
	UserListItem,
	TranslationSummary,
} from "../lib/api";
import { fetchBylines, getPreviewUrl, getDraftStatus } from "../lib/api";
import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js";
import { useDebouncedValue } from "../lib/hooks.js";
import { formatFileSize, getFileIcon } from "../lib/media-utils";
import { usePluginAdmins } from "../lib/plugin-context.js";
import { contentUrl, isSafeUrl } from "../lib/url.js";
import { cn, slugify } from "../lib/utils";
import { ArrowPrev } from "./ArrowIcons.js";
import { BlockKitFieldWidget } from "./BlockKitFieldWidget.js";
import { DocumentOutline } from "./editor/DocumentOutline";
import { ImageFieldRenderer, type ImageFieldValue } from "./ImageFieldRenderer.js";
import { PluginFieldErrorBoundary } from "./PluginFieldErrorBoundary.js";
import { RepeaterField } from "./RepeaterField.js";
import { RouterLinkButton } from "./RouterLinkButton.js";

/** Autosave debounce delay in milliseconds */
const AUTOSAVE_DELAY = 2000;

function serializeEditorState(input: {
	data: Record<string, unknown>;
	slug: string;
	bylines: BylineCreditInput[];
}) {
	return JSON.stringify({
		data: input.data,
		slug: input.slug,
		bylines: input.bylines,
	});
}

import type { ContentSeoInput } from "../lib/api";
import { ImageDetailPanel } from "./editor/ImageDetailPanel";
import type { ImageAttributes } from "./editor/ImageDetailPanel";
import { MediaPickerModal } from "./MediaPickerModal";
import {
	PortableTextEditor,
	type PluginBlockDef,
	type BlockSidebarPanel,
} from "./PortableTextEditor";
import { RevisionHistory } from "./RevisionHistory";
import { SaveButton } from "./SaveButton";
import { SeoPanel } from "./SeoPanel";
import { TaxonomySidebar } from "./TaxonomySidebar";
import { TranslationsPanel } from "./TranslationsPanel.js";

// Editor role level (40) from @emdash-cms/auth
const ROLE_EDITOR = 40;

export interface FieldDescriptor {
	id?: string;
	kind: string;
	label?: string;
	required?: boolean;
	/**
	 * For `select` / `multiSelect`: the list of enum choices.
	 * For `json` fields driven by a plugin `widget`: arbitrary widget config.
	 */
	options?: Array<{ value: string; label: string }> | Record<string, unknown>;
	widget?: string;
	validation?: Record<string, unknown>;
}

/** Simplified user info for current user context */
export interface CurrentUserInfo {
	id: string;
	role: number;
}

export interface ContentEditorProps {
	collection: string;
	collectionLabel: string;
	item?: ContentItem | null;
	fields: Record<string, FieldDescriptor>;
	isNew?: boolean;
	/**
	 * Locale this entry is bound to. For existing entries this matches
	 * `item.locale`; for new entries it's the URL `?locale=` (or default).
	 * Threaded into the byline picker so the empty-state CTA links to the
	 * right locale on the Bylines manager.
	 */
	entryLocale?: string | null;
	isSaving?: boolean;
	onSave?: (payload: {
		data: Record<string, unknown>;
		slug?: string;
		bylines?: BylineCreditInput[];
	}) => void;
	/** Callback for autosave (debounced, skips revision creation) */
	onAutosave?: (payload: {
		data: Record<string, unknown>;
		slug?: string;
		bylines?: BylineCreditInput[];
	}) => void;
	/** Whether autosave is in progress */
	isAutosaving?: boolean;
	/** Last autosave timestamp (for UI indicator) */
	lastAutosaveAt?: Date | null;
	onPublish?: () => void;
	onUnpublish?: () => void;
	/** Callback to discard draft changes (revert to published version) */
	onDiscardDraft?: () => void;
	/** Callback to schedule for future publishing */
	onSchedule?: (scheduledAt: string) => void;
	/** Callback to cancel scheduling (revert to draft) */
	onUnschedule?: () => void;
	/** Whether scheduling is in progress */
	isScheduling?: boolean;
	/** Whether this collection supports drafts */
	supportsDrafts?: boolean;
	/** Whether this collection supports revisions */
	supportsRevisions?: boolean;
	/** Whether this collection supports preview */
	supportsPreview?: boolean;
	/** Current user (for permission checks) */
	currentUser?: CurrentUserInfo;
	/** Available users for author selection (only shown to editors+) */
	users?: UserListItem[];
	/** Callback when author is changed */
	onAuthorChange?: (authorId: string | null) => void;
	/** Available byline profiles */
	availableBylines?: BylineSummary[];
	/** Whether the parent's byline picker query has resolved. Suppresses the empty-state flash before first fetch. */
	availableBylinesLoaded?: boolean;
	/** Selected byline credits (controlled for new entries) */
	selectedBylines?: BylineCreditInput[];
	/** Callback when byline credits are changed */
	onBylinesChange?: (bylines: BylineCreditInput[]) => void;
	/** Callback for creating a byline inline from the editor */
	onQuickCreateByline?: (input: { slug: string; displayName: string }) => Promise<BylineSummary>;
	/** Callback for updating a byline inline from the editor */
	onQuickEditByline?: (
		bylineId: string,
		input: { slug: string; displayName: string },
	) => Promise<BylineSummary>;
	/** Callback when item is deleted (moved to trash) */
	onDelete?: () => void;
	/** Whether delete is in progress */
	isDeleting?: boolean;
	/** i18n config — present when multiple locales are configured */
	i18n?: { defaultLocale: string; locales: string[] };
	/** Existing translations for this content item */
	translations?: TranslationSummary[];
	/** Callback to create a translation for a locale */
	onTranslate?: (locale: string) => void;
	/** Plugin block types available for insertion in Portable Text fields */
	pluginBlocks?: PluginBlockDef[];
	/** Whether this collection has SEO fields enabled */
	hasSeo?: boolean;
	/** Callback when SEO fields change */
	onSeoChange?: (seo: ContentSeoInput) => void;
	/** Admin manifest for resolving plugin field widgets */
	manifest?: import("../lib/api/client.js").AdminManifest | null;
}

/** Format scheduled date for display */
function formatScheduledDate(dateStr: string | null) {
	if (!dateStr) return null;
	const date = new Date(dateStr);
	return date.toLocaleString();
}

/**
 * Content editor with dynamic field rendering
 */
export function ContentEditor({
	collection,
	collectionLabel,
	item,
	fields,
	isNew,
	entryLocale,
	isSaving,
	onSave,
	onAutosave,
	isAutosaving,
	lastAutosaveAt,
	onPublish,
	onUnpublish,
	onDiscardDraft,
	onSchedule,
	onUnschedule,
	isScheduling,
	supportsDrafts = false,
	supportsRevisions = false,
	supportsPreview = false,
	currentUser,
	users,
	onAuthorChange,
	availableBylines,
	availableBylinesLoaded,
	selectedBylines,
	onBylinesChange,
	onQuickCreateByline,
	onQuickEditByline,
	onDelete,
	isDeleting,
	i18n,
	translations,
	onTranslate,
	pluginBlocks,
	hasSeo = false,
	onSeoChange,
	manifest,
}: ContentEditorProps) {
	const { t } = useLingui();
	const navigate = useNavigate();
	const [formData, setFormData] = React.useState<Record<string, unknown>>(item?.data || {});
	const [slug, setSlug] = React.useState(item?.slug || "");
	const [slugTouched, setSlugTouched] = React.useState(!!item?.slug);
	const [status, setStatus] = React.useState(item?.status || "draft");
	const [internalBylines, setInternalBylines] = React.useState<BylineCreditInput[]>(
		item?.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ??
			[],
	);
	// Gates whether `bylines` is included in the save payload. Untouched
	// edits must not ship `[]` — strict per-locale hydration can return
	// empty for entries with credits at other locales, and sending `[]`
	// would wipe them.
	const [bylinesTouched, setBylinesTouched] = React.useState(false);

	// Track portableText editor for document outline. Only the "content"
	// field wires its editor into this slot (see onEditorReady below).
	const [portableTextEditor, setPortableTextEditor] = React.useState<Editor | null>(null);

	// Block sidebar state – when a block (e.g. image) requests sidebar space, this holds
	// the panel data. When non-null the sidebar shows the block panel instead of the
	// default content settings sections.
	const [blockSidebarPanel, setBlockSidebarPanel] = React.useState<BlockSidebarPanel | null>(null);

	const handleBlockSidebarOpen = React.useCallback((panel: BlockSidebarPanel) => {
		setBlockSidebarPanel(panel);
	}, []);

	const handleBlockSidebarClose = React.useCallback(() => {
		setBlockSidebarPanel((prev) => {
			prev?.onClose();
			return null;
		});
	}, []);

	const handleSeoChange = React.useCallback(
		(seo: ContentSeoInput) => {
			onSeoChange?.(seo);
		},
		[onSeoChange],
	);

	// Track the last saved state to determine if dirty
	const [lastSavedData, setLastSavedData] = React.useState<string>(
		serializeEditorState({
			data: item?.data || {},
			slug: item?.slug || "",
			bylines:
				item?.bylines?.map((entry) => ({
					bylineId: entry.byline.id,
					roleLabel: entry.roleLabel,
				})) ?? [],
		}),
	);
	const pendingAutosaveStateRef = React.useRef<string | null>(null);

	// Synchronously reset form state when the underlying item changes (e.g. a
	// translation switch where TanStack Router keeps ContentEditor mounted but
	// swaps `item` for a different id). The post-render useEffect below also
	// syncs item -> formData, but it runs *after* the first render with the new
	// item, leaving children (notably PortableTextEditor, which freezes its
	// initial content on mount) one render behind. This is the React-recommended
	// "store info from previous renders" idiom -- see
	// https://react.dev/reference/react/useState#storing-information-from-previous-renders
	//
	// We also reset lastSavedData here (not just in the post-render effect) so
	// that isDirty stays false through the switch -- otherwise SaveButton would
	// briefly flip from "Saved" -> "Save" -> "Saved" within a single tick.
	const [previousItemId, setPreviousItemId] = React.useState<string | null>(item?.id ?? null);
	if (item && item.id !== previousItemId) {
		setPreviousItemId(item.id);
		setFormData(item.data);
		setSlug(item.slug || "");
		setSlugTouched(!!item.slug);
		setStatus(item.status);
		const nextBylines =
			item.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ??
			[];
		setInternalBylines(nextBylines);
		setLastSavedData(
			serializeEditorState({
				data: item.data,
				slug: item.slug || "",
				bylines: nextBylines,
			}),
		);
		pendingAutosaveStateRef.current = null;
		setBylinesTouched(false);
	}

	// Update form and last saved state when item changes (e.g., after save or restore)
	// Stringify the data for comparison since objects are compared by reference
	const itemDataString = React.useMemo(() => (item ? JSON.stringify(item.data) : ""), [item?.data]);
	React.useEffect(() => {
		if (item) {
			setFormData(item.data);
			setSlug(item.slug || "");
			setSlugTouched(!!item.slug);
			setStatus(item.status);
			setInternalBylines(
				item.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ??
					[],
			);
			setLastSavedData(
				serializeEditorState({
					data: item.data,
					slug: item.slug || "",
					bylines:
						item.bylines?.map((entry) => ({
							bylineId: entry.byline.id,
							roleLabel: entry.roleLabel,
						})) ?? [],
				}),
			);
			pendingAutosaveStateRef.current = null;
			setBylinesTouched(false);
		}
	}, [item?.updatedAt, itemDataString, item?.slug, item?.status]);

	const activeBylines = isNew ? (selectedBylines ?? []) : internalBylines;

	const handleBylinesChange = React.useCallback(
		(next: BylineCreditInput[]) => {
			setBylinesTouched(true);
			if (isNew) {
				onBylinesChange?.(next);
				return;
			}
			setInternalBylines(next);
			onBylinesChange?.(next);
		},
		[isNew, onBylinesChange],
	);

	// Check if form has unsaved changes
	const currentData = React.useMemo(
		() =>
			serializeEditorState({
				data: formData,
				slug,
				bylines: activeBylines,
			}),
		[formData, slug, activeBylines],
	);
	const isDirty = isNew || currentData !== lastSavedData;

	// Autosave with debounce
	// Track pending autosave to cancel on manual save
	const autosaveTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
	const formDataRef = React.useRef(formData);
	formDataRef.current = formData;
	const slugRef = React.useRef(slug);
	slugRef.current = slug;

	React.useEffect(() => {
		if (!lastAutosaveAt || !pendingAutosaveStateRef.current) {
			return;
		}

		setLastSavedData(pendingAutosaveStateRef.current);
		pendingAutosaveStateRef.current = null;
	}, [lastAutosaveAt]);

	const hasInvalidUrls = React.useCallback(
		(data: Record<string, unknown>) => {
			for (const [name, field] of Object.entries(fields)) {
				if (field.kind === "url") {
					const val = typeof data[name] === "string" ? data[name].trim() : "";
					if (val && !isValidUrl(val)) return true;
				}
			}
			return false;
		},
		[fields],
	);

	React.useEffect(() => {
		// Don't autosave for new items (no ID yet) or if autosave isn't configured
		if (isNew || !onAutosave || !item?.id) {
			return;
		}

		// Don't autosave if not dirty or already saving
		if (!isDirty || isSaving || isAutosaving) {
			return;
		}

		// Clear any pending autosave
		if (autosaveTimeoutRef.current) {
			clearTimeout(autosaveTimeoutRef.current);
		}

		// Schedule autosave
		autosaveTimeoutRef.current = setTimeout(() => {
			if (hasInvalidUrls(formDataRef.current)) return;
			const payload: {
				data: Record<string, unknown>;
				slug?: string;
				bylines?: BylineCreditInput[];
			} = {
				data: formDataRef.current,
				slug: slugRef.current || undefined,
			};
			if (bylinesTouched) payload.bylines = activeBylines;
			pendingAutosaveStateRef.current = serializeEditorState({
				data: payload.data,
				slug: payload.slug || "",
				bylines: activeBylines,
			});
			onAutosave(payload);
		}, AUTOSAVE_DELAY);

		return () => {
			if (autosaveTimeoutRef.current) {
				clearTimeout(autosaveTimeoutRef.current);
			}
		};
	}, [
		currentData,
		isNew,
		onAutosave,
		item?.id,
		isDirty,
		isSaving,
		isAutosaving,
		activeBylines,
		bylinesTouched,
		hasInvalidUrls,
	]);

	// Cancel pending autosave on manual save
	const handleSubmit = (e: React.FormEvent) => {
		e.preventDefault();
		if (hasInvalidUrls(formData)) return;
		// Cancel pending autosave
		if (autosaveTimeoutRef.current) {
			clearTimeout(autosaveTimeoutRef.current);
			autosaveTimeoutRef.current = null;
		}
		const payload: {
			data: Record<string, unknown>;
			slug?: string;
			bylines?: BylineCreditInput[];
		} = {
			data: formData,
			slug: slug || undefined,
		};
		if (isNew || bylinesTouched) payload.bylines = activeBylines;
		onSave?.(payload);
	};

	// Preview URL state
	const [isLoadingPreview, setIsLoadingPreview] = React.useState(false);

	const urlPattern = manifest?.collections[collection]?.urlPattern;

	const handlePreview = async () => {
		if (!item?.id) return;

		setIsLoadingPreview(true);
		try {
			const result = await getPreviewUrl(collection, item.id);
			if (result?.url) {
				window.open(result.url, "_blank", "noopener,noreferrer");
			} else {
				window.open(
					contentUrl(collection, slug || item.id, urlPattern),
					"_blank",
					"noopener,noreferrer",
				);
			}
		} catch {
			window.open(
				contentUrl(collection, slug || item?.id || "", urlPattern),
				"_blank",
				"noopener,noreferrer",
			);
		} finally {
			setIsLoadingPreview(false);
		}
	};

	const handleFieldChange = React.useCallback(
		(name: string, value: unknown) => {
			setFormData((prev) => ({ ...prev, [name]: value }));
			if (name === "title" && !slugTouched && typeof value === "string" && value) {
				setSlug(slugify(value));
			}
		},
		[slugTouched],
	);

	const handleSlugChange = (value: string) => {
		setSlug(value);
		setSlugTouched(true);
	};

	const isPublished = status === "published";

	// Draft revision status (only meaningful when supportsDrafts is on)
	const draftStatus = item ? getDraftStatus(item) : "unpublished";
	const hasPendingChanges = draftStatus === "published_with_changes";
	const isLive = draftStatus === "published" || draftStatus === "published_with_changes";

	// Scheduling — keyed off scheduledAt rather than status, since published
	// posts can now have a pending schedule without changing status.
	const hasSchedule = Boolean(item?.scheduledAt);
	const canSchedule =
		!isNew && !hasSchedule && Boolean(onSchedule) && (!isPublished || hasPendingChanges);

	// Schedule datetime state
	const [scheduleDate, setScheduleDate] = React.useState<string>("");
	const [showScheduler, setShowScheduler] = React.useState(false);

	// Distraction-free mode state
	const [isDistractionFree, setIsDistractionFree] = React.useState(false);

	// Escape exits distraction-free mode
	React.useEffect(() => {
		if (!isDistractionFree) return;

		const handleKeyDown = (e: KeyboardEvent) => {
			if (e.key === "Escape") {
				e.preventDefault();
				e.stopPropagation();
				setIsDistractionFree(false);
			}
		};

		document.addEventListener("keydown", handleKeyDown, { capture: true });
		return () => document.removeEventListener("keydown", handleKeyDown, { capture: true });
	}, [isDistractionFree]);

	const handleScheduleSubmit = () => {
		if (scheduleDate && onSchedule) {
			// Convert local datetime to ISO string
			const date = new Date(scheduleDate);
			onSchedule(date.toISOString());
			setShowScheduler(false);
			setScheduleDate("");
		}
	};

	return (
		<form
			onSubmit={handleSubmit}
			className={cn(
				"space-y-6 transition-all duration-300",
				isDistractionFree && "fixed inset-0 z-50 bg-kumo-base p-8 overflow-auto",
			)}
		>
			{/* Header. In distraction-free mode this becomes a hover-revealed
			    overlay so the chrome stays out of the way while writing. In
			    normal mode it's a regular block; the form also renders a
			    Save button at the bottom so save is reachable without
			    scrolling back up. */}
			<div
				className={cn(
					"flex flex-wrap items-center justify-between gap-y-2",
					isDistractionFree &&
						"opacity-0 hover:opacity-100 transition-opacity duration-200 fixed top-0 start-0 end-0 bg-kumo-base/95 backdrop-blur p-4 z-10",
				)}
			>
				<div className="flex items-center space-x-4">
					{!isDistractionFree && (
						<RouterLinkButton
							to="/content/$collection"
							params={{ collection }}
							search={{ locale: undefined }}
							aria-label={t`Back to ${collectionLabel} list`}
							variant="ghost"
							shape="square"
							icon={<ArrowPrev />}
						/>
					)}
					{isDistractionFree && (
						<Button
							variant="ghost"
							shape="square"
							onClick={() => setIsDistractionFree(false)}
							aria-label={t`Exit distraction-free mode`}
						>
							<ArrowsInSimple className="h-5 w-5" aria-hidden="true" />
						</Button>
					)}
					<h1 className="text-2xl font-bold">
						{isNew ? t`New ${collectionLabel}` : t`Edit ${collectionLabel}`}
					</h1>
					{i18n && item?.locale && (
						<Badge variant="outline" className="uppercase text-xs">
							{item.locale}
						</Badge>
					)}
				</div>
				<div className="flex items-center space-x-2">
					{/* Autosave indicator */}
					{!isNew && onAutosave && (
						<div
							className="flex items-center text-xs text-kumo-subtle"
							role="status"
							aria-label={t`Autosave status`}
							aria-live="polite"
						>
							{isAutosaving ? (
								<>
									<Loader size="sm" />
									<span className="ms-1">{t`Saving...`}</span>
								</>
							) : lastAutosaveAt ? (
								<>
									<Check className="me-1 h-3 w-3 text-green-600" aria-hidden="true" />
									<span>{t`Saved`}</span>
								</>
							) : null}
						</div>
					)}
					{!isDistractionFree && (
						<Button
							variant="ghost"
							shape="square"
							type="button"
							onClick={() => setIsDistractionFree(true)}
							aria-label={t`Enter distraction-free mode`}
							title={t`Distraction-free mode (⌘⇧\\)`}
						>
							<ArrowsOutSimple className="h-4 w-4" aria-hidden="true" />
						</Button>
					)}
					{!isNew && supportsPreview && (
						<Button
							variant="outline"
							type="button"
							onClick={handlePreview}
							disabled={isLoadingPreview}
							icon={isLoadingPreview ? <Loader size="sm" /> : <Eye />}
						>
							{hasPendingChanges ? t`Preview draft` : t`Preview`}
						</Button>
					)}
					<SaveButton type="submit" isDirty={isDirty} isSaving={isSaving || false} />
					{!isNew && (
						<>
							{supportsDrafts && hasPendingChanges && onDiscardDraft && (
								<Dialog.Root>
									<Dialog.Trigger
										render={(p) => (
											<Button {...p} type="button" variant="outline" icon={<X />}>
												{t`Discard changes`}
											</Button>
										)}
									/>
									<Dialog className="p-6" size="sm">
										<Dialog.Title className="text-lg font-semibold">
											{t`Discard draft changes?`}
										</Dialog.Title>
										<Dialog.Description className="text-kumo-subtle">
											{t`This will revert to the published version. Your draft changes will be lost.`}
										</Dialog.Description>
										<div className="mt-6 flex justify-end gap-2">
											<Dialog.Close
												render={(p) => (
													<Button {...p} variant="secondary">
														{t`Cancel`}
													</Button>
												)}
											/>
											<Dialog.Close
												render={(p) => (
													<Button {...p} variant="destructive" onClick={onDiscardDraft}>
														{t`Discard changes`}
													</Button>
												)}
											/>
										</div>
									</Dialog>
								</Dialog.Root>
							)}
							{isLive ? (
								<>
									{hasPendingChanges ? (
										<Button type="button" variant="primary" onClick={onPublish}>
											{t`Publish changes`}
										</Button>
									) : (
										<Button type="button" variant="outline" onClick={onUnpublish}>
											{t`Unpublish`}
										</Button>
									)}
								</>
							) : (
								<Button type="button" variant="secondary" onClick={onPublish}>
									{t`Publish`}
								</Button>
							)}
							{isLive && item?.slug && (
								<LinkButton
									href={contentUrl(collection, item.slug, urlPattern)}
									external
									variant="outline"
									icon={<ArrowSquareOut />}
								>
									{t`Live View`}
								</LinkButton>
							)}
						</>
					)}
				</div>
			</div>

			{/* Main content area */}
			<div
				className={cn(
					"grid gap-6 lg:grid-cols-3",
					isDistractionFree && "lg:grid-cols-1 max-w-4xl mx-auto pt-16",
				)}
			>
				{/* Editor fields */}
				<div className="space-y-6 lg:col-span-2">
					<div
						className={cn(
							"rounded-lg border bg-kumo-base p-6",
							isDistractionFree && "border-0 bg-transparent p-0",
						)}
					>
						<div className="space-y-4">
							{Object.entries(fields).map(([name, field]) => {
								// Key by item id so all field editors remount cleanly when the
								// underlying content item changes (e.g. switching translations).
								// PortableTextEditor in particular freezes its initial content on
								// mount; without this key, navigating between translations leaves
								// the previous locale's body in the editor and silently overwrites
								// the new translation on the next edit.
								const fieldKey = `${name}:${item?.id ?? "new"}`;
								const fieldEl = (
									<FieldRenderer
										key={fieldKey}
										name={name}
										field={field}
										value={formData[name]}
										onChange={handleFieldChange}
										onEditorReady={
											field.kind === "portableText" && name === "content"
												? setPortableTextEditor
												: undefined
										}
										minimal={isDistractionFree}
										pluginBlocks={pluginBlocks}
										onBlockSidebarOpen={
											field.kind === "portableText" ? handleBlockSidebarOpen : undefined
										}
										onBlockSidebarClose={
											field.kind === "portableText" ? handleBlockSidebarClose : undefined
										}
										manifest={manifest}
									/>
								);
								return fieldEl;
							})}
						</div>
					</div>

					{/* Save action at the bottom of the main column so users hit it
					    naturally when they finish editing, without needing to scroll
					    past the entire sidebar. */}
					{!isDistractionFree && (
						<div className="flex justify-end">
							<SaveButton type="submit" isDirty={isDirty} isSaving={isSaving || false} />
						</div>
					)}
				</div>

				{/* Sidebar - hidden in distraction-free mode */}
				<div className={cn("space-y-6", isDistractionFree && "hidden")}>
					{blockSidebarPanel ? (
						/* Block sidebar panel – replaces default sections when a block requests it */
						blockSidebarPanel.type === "image" ? (
							<ImageDetailPanel
								attributes={blockSidebarPanel.attrs as unknown as ImageAttributes}
								onUpdate={(attrs) =>
									blockSidebarPanel.onUpdate(attrs as unknown as Record<string, unknown>)
								}
								onReplace={(attrs) =>
									blockSidebarPanel.onReplace(attrs as unknown as Record<string, unknown>)
								}
								onDelete={() => {
									blockSidebarPanel.onDelete();
									setBlockSidebarPanel(null);
								}}
								onClose={handleBlockSidebarClose}
								inline
							/>
						) : null
					) : (
						/* Default content settings sections – single card with dividers */
						<div className="rounded-lg border bg-kumo-base flex flex-col">
							{/* Publish settings */}
							<div className="p-4">
								<h3 className="mb-4 font-semibold">{t`Publish`}</h3>
								<div className="space-y-4">
									<Input
										label={t`Slug`}
										value={slug}
										onChange={(e) => handleSlugChange(e.target.value)}
										placeholder="my-post-slug"
									/>
									<div>
										<Label>{t`Status`}</Label>
										<div className="mt-1 flex flex-wrap items-center gap-1.5">
											{supportsDrafts ? (
												<>
													{isLive && <Badge variant="success">{t`Published`}</Badge>}
													{hasPendingChanges && (
														<Badge variant="secondary">{t`Pending changes`}</Badge>
													)}
													{!isLive && !hasSchedule && <Badge variant="secondary">{t`Draft`}</Badge>}
													{hasSchedule && <Badge variant="outline">{t`Scheduled`}</Badge>}
												</>
											) : (
												<span className="text-sm text-kumo-subtle">
													{status.charAt(0).toUpperCase() + status.slice(1)}
												</span>
											)}
										</div>
										{item?.scheduledAt && (
											<div className="mt-2 flex items-center justify-between gap-2 rounded-md border px-3 py-2">
												<p className="text-xs text-kumo-subtle">{t`Scheduled for: ${formatScheduledDate(item.scheduledAt)}`}</p>
												<Button type="button" variant="outline" size="sm" onClick={onUnschedule}>
													{t`Unschedule`}
												</Button>
											</div>
										)}
									</div>

									{canSchedule && (
										<div className="pt-2">
											{showScheduler ? (
												<div className="space-y-2">
													<Input
														label={t`Schedule for`}
														type="datetime-local"
														value={scheduleDate}
														onChange={(e) => setScheduleDate(e.target.value)}
														min={new Date().toISOString().slice(0, 16)}
													/>
													<div className="flex gap-2">
														<Button
															type="button"
															size="sm"
															onClick={handleScheduleSubmit}
															disabled={!scheduleDate || isScheduling}
															icon={isScheduling ? <Loader size="sm" /> : undefined}
														>
															{t`Schedule`}
														</Button>
														<Button
															type="button"
															variant="outline"
															size="sm"
															onClick={() => {
																setShowScheduler(false);
																setScheduleDate("");
															}}
														>
															{t`Cancel`}
														</Button>
													</div>
												</div>
											) : (
												<Button
													type="button"
													variant="outline"
													size="sm"
													className="w-full"
													onClick={() => setShowScheduler(true)}
												>
													{t`Schedule for later`}
												</Button>
											)}
										</div>
									)}

									{item && (
										<div className="text-xs text-kumo-subtle">
											<p>{t`Created: ${new Date(item.createdAt).toLocaleString()}`}</p>
											<p>{t`Updated: ${new Date(item.updatedAt).toLocaleString()}`}</p>
										</div>
									)}
									{!isNew && onDelete && (
										<div className="pt-4 border-t">
											<Dialog.Root disablePointerDismissal>
												<Dialog.Trigger
													render={(p) => (
														<Button
															{...p}
															type="button"
															variant="outline"
															className="w-full text-kumo-danger hover:text-kumo-danger"
															disabled={isDeleting}
															icon={isDeleting ? <Loader size="sm" /> : <Trash />}
														>
															{t`Move to Trash`}
														</Button>
													)}
												/>
												<Dialog className="p-6" size="sm">
													<Dialog.Title className="text-lg font-semibold">
														{t`Move to Trash?`}
													</Dialog.Title>
													<Dialog.Description className="text-kumo-subtle">
														{t`This will move the item to trash. You can restore it later from the trash.`}
													</Dialog.Description>
													<div className="mt-6 flex justify-end gap-2">
														<Dialog.Close
															render={(p) => (
																<Button {...p} variant="secondary">
																	{t`Cancel`}
																</Button>
															)}
														/>
														<Dialog.Close
															render={(p) => (
																<Button {...p} variant="destructive" onClick={onDelete}>
																	{t`Move to Trash`}
																</Button>
															)}
														/>
													</div>
												</Dialog>
											</Dialog.Root>
										</div>
									)}
								</div>
							</div>

							{/* Ownership selector - shown only to editors and above */}
							{currentUser && currentUser.role >= ROLE_EDITOR && users && users.length > 0 && (
								<div className="p-4 border-t">
									<h3 className="mb-4 font-semibold">{t`Ownership`}</h3>
									<AuthorSelector
										authorId={item?.authorId || null}
										users={users}
										onChange={onAuthorChange}
									/>
								</div>
							)}

							{/* Byline credits */}
							{currentUser && currentUser.role >= ROLE_EDITOR && (
								<div className="p-4 border-t">
									<h3 className="mb-4 font-semibold">{t`Bylines`}</h3>
									<BylineCreditsEditor
										credits={activeBylines}
										bylines={availableBylines ?? []}
										selectedBylineDetails={item?.bylines?.map((entry) => entry.byline)}
										bylinesLoaded={availableBylinesLoaded}
										onChange={handleBylinesChange}
										onQuickCreate={onQuickCreateByline}
										onQuickEdit={onQuickEditByline}
										// Existing entry: use its own locale. New entry: use the
										// URL `?locale=` (passed in via `entryLocale`).
										entryLocale={item?.locale ?? entryLocale}
										i18n={i18n}
									/>
								</div>
							)}

							{/* Translations sidebar - shown when i18n is enabled */}
							{i18n && item && !isNew && (
								<div className="p-4 border-t">
									<TranslationsPanel
										locales={i18n.locales}
										defaultLocale={i18n.defaultLocale}
										currentLocale={item.locale ?? undefined}
										translations={translations ?? []}
										onOpen={(tr) =>
											navigate({
												to: "/content/$collection/$id",
												params: { collection, id: tr.id },
												search: { locale: tr.locale },
											})
										}
										onCreate={onTranslate}
									/>
								</div>
							)}

							{/* Taxonomy selector */}
							{item && (
								<div className="p-4 border-t">
									<TaxonomySidebar
										collection={collection}
										entryId={item.id}
										entryLocale={item.locale ?? entryLocale}
									/>
								</div>
							)}

							{/* SEO panel - shown for collections with hasSeo enabled */}
							{hasSeo && !isNew && onSeoChange && (
								<div className="p-4 border-t">
									<h3 className="mb-4 font-semibold flex items-center gap-2">
										<MagnifyingGlass className="h-4 w-4" />
										{t`SEO`}
									</h3>
									<SeoPanel
										contentKey={item?.id ?? `new:${collection}`}
										seo={item?.seo}
										onChange={handleSeoChange}
									/>
								</div>
							)}

							{/* Document outline - shown when editing content with portableText */}
							{portableTextEditor && (
								<div className="p-4 border-t">
									<DocumentOutline editor={portableTextEditor} />
								</div>
							)}

							{/* Revision history - shown for existing items in collections that support it */}
							{!isNew && item && supportsRevisions && (
								<div className="p-4 border-t">
									<RevisionHistory collection={collection} entryId={item.id} />
								</div>
							)}
						</div>
					)}
				</div>
			</div>
		</form>
	);
}

interface FieldRendererProps {
	name: string;
	field: FieldDescriptor;
	value: unknown;
	onChange: (name: string, value: unknown) => void;
	/** Callback when a portableText editor is ready.
	 * Called with the editor on mount, and with `null` on unmount. */
	onEditorReady?: (editor: Editor | null) => void;
	/** Minimal chrome - hides toolbar, fades labels, removes borders (distraction-free mode) */
	minimal?: boolean;
	/** Plugin block types available for insertion in Portable Text fields */
	pluginBlocks?: PluginBlockDef[];
	/** Callback when a block node requests sidebar space */
	onBlockSidebarOpen?: (panel: BlockSidebarPanel) => void;
	/** Callback when a block node closes its sidebar */
	onBlockSidebarClose?: () => void;
	/** Admin manifest for resolving sandboxed field widget elements */
	manifest?: import("../lib/api/client.js").AdminManifest | null;
}

/**
 * Render field based on type
 */
function FieldRenderer({
	name,
	field,
	value,
	onChange,
	onEditorReady,
	minimal,
	pluginBlocks,
	onBlockSidebarOpen,
	onBlockSidebarClose,
	manifest,
}: FieldRendererProps) {
	const { t } = useLingui();
	const pluginAdmins = usePluginAdmins();
	const label = field.label || name.charAt(0).toUpperCase() + name.slice(1);
	const id = `field-${name}`;
	const labelClass = minimal ? "text-kumo-subtle/50 text-xs font-normal" : undefined;

	const handleChange = React.useCallback((v: unknown) => onChange(name, v), [onChange, name]);

	// Check for plugin field widget override
	if (field.widget) {
		const sepIdx = field.widget.indexOf(":");
		if (sepIdx <= 0) {
			console.warn(
				`[emdash] Field "${name}" has widget "${field.widget}" but it should use the format "pluginId:widgetName". Falling back to default editor.`,
			);
		}
		if (sepIdx > 0) {
			const pluginId = field.widget.slice(0, sepIdx);
			const widgetName = field.widget.slice(sepIdx + 1);
			// Trusted plugin: React component
			const PluginField = pluginAdmins[pluginId]?.fields?.[widgetName] as
				| React.ComponentType<{
						value: unknown;
						onChange: (value: unknown) => void;
						label: string;
						id: string;
						required?: boolean;
						options?: Array<{ value: string; label: string }> | Record<string, unknown>;
						minimal?: boolean;
				  }>
				| undefined;
			if (typeof PluginField === "function") {
				return (
					<PluginFieldErrorBoundary fieldKind={field.kind}>
						<PluginField
							value={value}
							onChange={handleChange}
							label={label}
							id={id}
							required={field.required}
							options={field.options}
							minimal={minimal}
						/>
					</PluginFieldErrorBoundary>
				);
			}
			// Sandboxed plugin: Block Kit elements from manifest
			if (manifest) {
				const pluginManifest = manifest.plugins[pluginId];
				const widgetDef = pluginManifest?.fieldWidgets?.find((w) => w.name === widgetName);
				if (widgetDef?.elements && widgetDef.elements.length > 0) {
					return (
						<PluginFieldErrorBoundary fieldKind={field.kind}>
							<BlockKitFieldWidget
								label={label}
								elements={widgetDef.elements}
								value={value}
								onChange={handleChange}
							/>
						</PluginFieldErrorBoundary>
					);
				}
			}
			// Widget declared but plugin not found/active -- fall through to default
		}
	}

	switch (field.kind) {
		case "string":
			return (
				<Input
					label={<span className={labelClass}>{label}</span>}
					id={id}
					value={typeof value === "string" ? value : ""}
					onChange={(e) => handleChange(e.target.value)}
					required={field.required}
					dir="auto"
					className={
						minimal
							? "border-0 bg-transparent px-0 text-lg font-medium focus-visible:ring-0 focus-visible:ring-offset-0"
							: undefined
					}
				/>
			);

		case "number":
			return (
				<Input
					label={<span className={labelClass}>{label}</span>}
					id={id}
					type="number"
					value={typeof value === "number" ? value : ""}
					onChange={(e) => handleChange(Number(e.target.value))}
					required={field.required}
				/>
			);

		case "boolean":
			return (
				<Switch id={id} label={label} checked={Boolean(value)} onCheckedChange={handleChange} />
			);

		case "portableText": {
			const labelId = `${id}-label`;
			return (
				<div id={id}>
					{!minimal && (
						<span
							id={labelId}
							className={cn("text-sm font-medium leading-none text-kumo-default", labelClass)}
						>
							{label}
						</span>
					)}
					<PortableTextEditor
						value={Array.isArray(value) ? value : []}
						onChange={handleChange}
						placeholder={t`Enter ${label.toLowerCase()}...`}
						aria-labelledby={labelId}
						pluginBlocks={pluginBlocks}
						onEditorReady={onEditorReady}
						minimal={minimal}
						onBlockSidebarOpen={onBlockSidebarOpen}
						onBlockSidebarClose={onBlockSidebarClose}
					/>
				</div>
			);
		}

		case "richText":
			// For richText (markdown), use InputArea
			return (
				<InputArea
					label={label}
					id={id}
					value={typeof value === "string" ? value : ""}
					onChange={(e) => handleChange(e.target.value)}
					rows={10}
					dir="auto"
					placeholder={t`Enter markdown content...`}
				/>
			);

		case "select": {
			const selectOptions = Array.isArray(field.options) ? field.options : [];
			const selectItems: Record<string, string> = {};
			for (const opt of selectOptions) {
				selectItems[opt.value] = opt.label;
			}
			return (
				<Select
					id={id}
					label={label}
					value={typeof value === "string" ? value : ""}
					onValueChange={(v) => handleChange(v ?? "")}
					items={selectItems}
				>
					{selectOptions.map((opt) => (
						<Select.Option key={opt.value} value={opt.value}>
							{opt.label}
						</Select.Option>
					))}
				</Select>
			);
		}

		case "multiSelect": {
			const multiSelectOptions = Array.isArray(field.options) ? field.options : [];
			const selected: string[] = Array.isArray(value) ? (value as string[]) : [];
			return (
				<fieldset>
					<Label className={labelClass}>{label}</Label>
					<div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
						{multiSelectOptions.map((opt) => {
							const isChecked = selected.includes(opt.value);
							return (
								<Checkbox
									key={opt.value}
									label={opt.label}
									checked={isChecked}
									onCheckedChange={(checked) => {
										const next = checked
											? [...selected, opt.value]
											: selected.filter((v) => v !== opt.value);
										handleChange(next);
									}}
								/>
							);
						})}
					</div>
				</fieldset>
			);
		}

		case "datetime":
			return (
				<Input
					label={label}
					id={id}
					type="datetime-local"
					value={toDatetimeLocalInputValue(value)}
					onChange={(e) => handleChange(fromDatetimeLocalInputValue(e.target.value))}
					required={field.required}
				/>
			);

		case "image": {
			// value is either an ImageFieldValue object, a legacy string URL, or undefined
			const imageValue =
				value != null && typeof value === "object" ? (value as ImageFieldValue) : undefined;
			return (
				<ImageFieldRenderer
					id={id}
					label={label}
					description={
						name === "featured_image"
							? t`Used as the main visual for this post on listing pages and at the top of the post`
							: undefined
					}
					value={imageValue}
					onChange={handleChange}
					required={field.required}
					allowedMimeTypes={
						Array.isArray(field.validation?.allowedMimeTypes)
							? (field.validation.allowedMimeTypes as string[])
							: undefined
					}
					fieldId={field.id}
				/>
			);
		}

		case "file": {
			// value is either a FileFieldValue object or undefined.
			// The file field type was unusable before this PR (rendered as a text input
			// that produced raw strings nobody could meaningfully save), so there is no
			// "legacy string" data to preserve here.
			const fileValue =
				value != null && typeof value === "object" ? (value as FileFieldValue) : undefined;
			return (
				<FileFieldRenderer
					id={id}
					label={label}
					value={fileValue}
					onChange={handleChange}
					required={field.required}
					allowedMimeTypes={
						Array.isArray(field.validation?.allowedMimeTypes)
							? (field.validation.allowedMimeTypes as string[])
							: undefined
					}
					fieldId={field.id}
				/>
			);
		}

		case "repeater": {
			const validation = field.validation;
			const subFields = (validation?.subFields ?? []) as Array<{
				slug: string;
				type: string;
				label: string;
				required?: boolean;
				options?: string[];
			}>;
			return (
				<RepeaterField
					label={label}
					id={id}
					value={value}
					onChange={handleChange}
					required={field.required}
					subFields={subFields}
					minItems={typeof validation?.minItems === "number" ? validation.minItems : undefined}
					maxItems={typeof validation?.maxItems === "number" ? validation.maxItems : undefined}
				/>
			);
		}

		case "json": {
			const jsonString =
				typeof value === "string" ? value : value != null ? JSON.stringify(value, null, 2) : "";
			return (
				<JsonFieldEditor
					label={label}
					id={id}
					value={jsonString}
					onChange={handleChange}
					required={field.required}
				/>
			);
		}

		case "url":
			return (
				<UrlFieldEditor
					label={label}
					labelClass={labelClass}
					id={id}
					value={typeof value === "string" ? value : ""}
					onChange={handleChange}
					required={field.required}
					placeholder="https://"
				/>
			);

		default:
			// Default to text input
			return (
				<Input
					label={label}
					id={id}
					value={typeof value === "string" ? value : ""}
					onChange={(e) => handleChange(e.target.value)}
					required={field.required}
					dir="auto"
				/>
			);
	}
}

const URL_PROTOCOL_PATTERN = /^https?:\/\//;

function isValidUrl(val: string): boolean {
	if (!URL_PROTOCOL_PATTERN.test(val)) return false;
	try {
		const url = new URL(val);
		if (url.protocol !== "http:" && url.protocol !== "https:") return false;
		if (url.hostname.includes("..")) return false;
		return url.hostname.includes(".") || url.hostname === "localhost";
	} catch {
		return false;
	}
}

/**
 * URL field editor with validation on blur
 */
function UrlFieldEditor({
	label,
	labelClass,
	id,
	value,
	onChange,
	required,
	placeholder,
}: {
	label: string;
	labelClass?: string;
	id: string;
	value: string;
	onChange: (value: unknown) => void;
	required?: boolean;
	placeholder?: string;
}) {
	const { t } = useLingui();
	const [error, setError] = React.useState<string | null>(null);

	const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
		const val = e.target.value.trim();
		if (!val) {
			setError(null);
			return;
		}
		if (!isValidUrl(val)) {
			setError(t`Enter a valid URL (e.g. https://example.com)`);
		} else {
			setError(null);
		}
	};

	return (
		<div>
			<Input
				label={<span className={labelClass}>{label}</span>}
				id={id}
				type="url"
				value={value}
				onChange={(e) => {
					if (error) setError(null);
					onChange(e.target.value);
				}}
				onBlur={handleBlur}
				required={required}
				placeholder={placeholder}
			/>
			{error && <p className="text-sm text-kumo-danger mt-1">{error}</p>}
		</div>
	);
}

/**
 * JSON field editor with syntax validation
 */
function JsonFieldEditor({
	label,
	id,
	value,
	onChange,
	required,
}: {
	label: string;
	id: string;
	value: string;
	onChange: (value: unknown) => void;
	required?: boolean;
}) {
	const { t } = useLingui();
	const [text, setText] = React.useState(value);
	const [error, setError] = React.useState<string | null>(null);

	// Sync from parent when value changes externally
	React.useEffect(() => {
		setText(value);
		setError(null);
	}, [value]);

	const handleChange = (newText: string) => {
		setText(newText);
		setError(null);
	};

	const handleBlur = () => {
		const trimmed = text.trim();
		if (trimmed === "") {
			setError(null);
			onChange(null);
			return;
		}
		try {
			const parsed = JSON.parse(trimmed);
			setError(null);
			onChange(parsed);
		} catch {
			setError(t`Invalid JSON`);
		}
	};

	return (
		<div>
			<InputArea
				label={label}
				id={id}
				value={text}
				onChange={(e) => handleChange(e.target.value)}
				onBlur={handleBlur}
				rows={8}
				placeholder="{}"
				required={required}
				className="font-mono text-sm"
			/>
			{error && <p className="text-sm text-kumo-danger mt-1">{error}</p>}
		</div>
	);
}

// ImageFieldRenderer (and its ImageFieldValue shape) moved to
// ./ImageFieldRenderer so repeater sub-fields can reuse the picker.

/**
 * File field value — matches the "file" shape validated by the Zod generator:
 * { id, provider?, src?, filename?, mimeType?, size?, meta? }
 */
interface FileFieldValue {
	id: string;
	/** Provider ID (e.g., "local", "s3") */
	provider?: string;
	/** Direct URL for non-local media */
	src?: string;
	filename?: string;
	mimeType?: string;
	size?: number;
	/** Provider-specific metadata */
	meta?: Record<string, unknown>;
}

interface FileFieldRendererProps {
	id?: string;
	label: string;
	value: FileFieldValue | undefined;
	onChange: (value: FileFieldValue | null) => void;
	required?: boolean;
	allowedMimeTypes?: string[];
	fieldId?: string;
}

/**
 * File field with media picker
 *
 * Like ImageFieldRenderer but for arbitrary file types. Shows a mime-type-appropriate
 * icon, filename, and size instead of an image preview.
 */
function FileFieldRenderer({
	id,
	label,
	value,
	onChange,
	required,
	allowedMimeTypes,
	fieldId,
}: FileFieldRendererProps) {
	const { t } = useLingui();
	const [pickerOpen, setPickerOpen] = React.useState(false);

	// Normalize value to derive display info.
	// For local files, prefer meta.storageKey; fall back to value.src when it's an
	// internal media path; finally fall back to value.id so local files remain
	// clickable even when metadata is sparse. For external providers, use value.src
	// but only when it's an http(s) URL — a hostile provider plugin could otherwise
	// return a data: or javascript: URL that gets rendered as a clickable link.
	const normalized = React.useMemo(() => {
		if (!value) return null;
		const isLocal = !value.provider || value.provider === "local";
		const storageKey =
			typeof value.meta?.storageKey === "string" ? value.meta.storageKey : undefined;
		const localSrc =
			typeof value.src === "string" && value.src.startsWith("/_emdash/") ? value.src : undefined;
		// Storage keys come from server-controlled paths today, but the Zod schema
		// now lets clients write arbitrary `meta.storageKey` strings via the content
		// API. Encode before interpolating so attacker-shaped values can't escape
		// the path with `?` or `#`.
		const localUrl = isLocal
			? storageKey
				? `/_emdash/api/media/file/${encodeURIComponent(storageKey)}`
				: (localSrc ?? `/_emdash/api/media/file/${encodeURIComponent(value.id)}`)
			: undefined;
		const externalUrl = !isLocal && value.src && isSafeUrl(value.src) ? value.src : undefined;
		return {
			displayUrl: localUrl ?? externalUrl,
			filename: value.filename || t`Untitled file`,
			mimeType: value.mimeType || "",
			size: value.size,
		};
	}, [value, t]);

	const handleSelect = (item: MediaItem) => {
		const isLocalProvider = !item.provider || item.provider === "local";
		onChange({
			id: item.id,
			provider: item.provider || "local",
			src: isLocalProvider ? undefined : item.url,
			filename: item.filename,
			mimeType: item.mimeType,
			size: item.size,
			meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta,
		});
	};

	const handleRemove = () => {
		onChange(null);
	};

	const hasMime = !!normalized?.mimeType;
	const size = typeof normalized?.size === "number" ? normalized.size : undefined;
	const hasSize = size !== undefined;

	return (
		<div id={id}>
			<Label>{label}</Label>
			{normalized ? (
				<div className="mt-2 flex items-center gap-3 rounded-lg border p-3">
					<span className="text-3xl" aria-hidden="true">
						{getFileIcon(normalized.mimeType)}
					</span>
					<div className="flex-1 min-w-0">
						{normalized.displayUrl ? (
							<a
								href={normalized.displayUrl}
								target="_blank"
								rel="noopener noreferrer"
								className="text-sm font-medium truncate block hover:underline"
							>
								{normalized.filename}
							</a>
						) : (
							<p className="text-sm font-medium truncate">{normalized.filename}</p>
						)}
						{(hasMime || hasSize) && (
							<p className="text-xs text-kumo-subtle">
								{hasMime ? normalized.mimeType : null}
								{hasMime && hasSize ? " • " : null}
								{hasSize ? formatFileSize(size) : null}
							</p>
						)}
					</div>
					<div className="flex gap-1">
						<Button type="button" size="sm" variant="secondary" onClick={() => setPickerOpen(true)}>
							{t`Change`}
						</Button>
						<Button
							type="button"
							shape="square"
							variant="destructive"
							className="h-8 w-8"
							onClick={handleRemove}
							aria-label={t`Remove ${label}`}
						>
							<X className="h-4 w-4" />
						</Button>
					</div>
				</div>
			) : (
				<Button
					type="button"
					variant="outline"
					className="mt-2 w-full h-32 border-dashed"
					onClick={() => setPickerOpen(true)}
					aria-label={t`Select ${label}`}
				>
					<div className="flex flex-col items-center gap-2 text-kumo-subtle">
						<Paperclip className="h-8 w-8" />
						<span>{t`Select file`}</span>
					</div>
				</Button>
			)}
			<MediaPickerModal
				open={pickerOpen}
				onOpenChange={setPickerOpen}
				onSelect={handleSelect}
				mimeTypeFilters={allowedMimeTypes ?? []}
				fieldId={fieldId}
				hideUrlInput
				mediaKind="file"
				title={t`Select ${label}`}
			/>
			{required && !normalized && (
				<p className="text-sm text-kumo-danger mt-1">{t`This field is required`}</p>
			)}
		</div>
	);
}

/**
 * Author selector component for editors and above
 */
interface AuthorSelectorProps {
	authorId: string | null;
	users: UserListItem[];
	onChange?: (authorId: string | null) => void;
}

interface BylineCreditsEditorProps {
	credits: BylineCreditInput[];
	bylines: BylineSummary[];
	/**
	 * Full byline details for the entry's already-selected credits. Seeded from
	 * the saved entry so credited bylines always render their name/slug even when
	 * they fall outside the initial (unsearched) picker list.
	 */
	selectedBylineDetails?: BylineSummary[];
	onChange: (bylines: BylineCreditInput[]) => void;
	onQuickCreate?: (input: { slug: string; displayName: string }) => Promise<BylineSummary>;
	onQuickEdit?: (
		bylineId: string,
		input: { slug: string; displayName: string },
	) => Promise<BylineSummary>;
	/**
	 * Locale of the entry being edited. When the picker comes back empty and
	 * the install is multi-locale, the empty-state copy and CTA link are
	 * scoped to this locale (post-migration 040, the picker is strict
	 * per-locale — see the bylines manager flow).
	 */
	entryLocale?: string | null;
	/** i18n config from the manifest. When set with >1 locales, the editor renders the locale-scoped empty-state. */
	i18n?: { defaultLocale: string; locales: string[] } | null;
	/** Suppresses the empty-state until the picker query resolves. Defaults to true. */
	bylinesLoaded?: boolean;
}

function BylineCreditsEditor({
	credits,
	bylines,
	selectedBylineDetails,
	onChange,
	onQuickCreate,
	onQuickEdit,
	entryLocale,
	i18n,
	bylinesLoaded = true,
}: BylineCreditsEditorProps) {
	const { t } = useLingui();
	const [search, setSearch] = React.useState("");
	const debouncedSearch = useDebouncedValue(search, 300);
	const [quickName, setQuickName] = React.useState("");
	const [quickSlug, setQuickSlug] = React.useState("");
	const [quickError, setQuickError] = React.useState<string | null>(null);
	const [isCreating, setIsCreating] = React.useState(false);
	const [editBylineId, setEditBylineId] = React.useState<string | null>(null);
	const [editName, setEditName] = React.useState("");
	const [editSlug, setEditSlug] = React.useState("");
	const [editError, setEditError] = React.useState<string | null>(null);
	const [isEditing, setIsEditing] = React.useState(false);

	// Server-side search so the picker isn't limited to the first page of
	// bylines (previously capped at 100 with no way to find the rest). When the
	// search box is empty we fall back to the parent-provided initial list.
	const trimmedSearch = debouncedSearch.trim();
	const searchEnabled = trimmedSearch.length > 0;
	const searchResults = useQuery({
		queryKey: ["bylines", "credit-picker", entryLocale ?? null, trimmedSearch],
		queryFn: () =>
			fetchBylines({ search: trimmedSearch, locale: entryLocale ?? undefined, limit: 20 }),
		enabled: searchEnabled,
		placeholderData: keepPreviousData,
	});

	const resultPool = searchEnabled ? (searchResults.data?.items ?? []) : bylines;
	const hasMoreResults = searchEnabled ? !!searchResults.data?.nextCursor : bylines.length >= 100;

	// Resolve credited bylines to their full details for display. Selected rows
	// come from the parent-provided details so they keep rendering even when the
	// current search results no longer include them.
	const bylineMap = React.useMemo(() => {
		const map = new Map<string, BylineSummary>();
		for (const b of selectedBylineDetails ?? []) map.set(b.id, b);
		for (const b of bylines) map.set(b.id, b);
		for (const b of searchResults.data?.items ?? []) map.set(b.id, b);
		return map;
	}, [selectedBylineDetails, bylines, searchResults.data?.items]);

	const availableToAdd = resultPool.filter((b) => !credits.some((c) => c.bylineId === b.id));

	const addByline = (bylineId: string) => {
		if (credits.some((c) => c.bylineId === bylineId)) return;
		onChange([...credits, { bylineId, roleLabel: null }]);
	};

	const move = (index: number, direction: -1 | 1) => {
		const target = index + direction;
		if (target < 0 || target >= credits.length) return;
		const next = [...credits];
		const [moved] = next.splice(index, 1);
		if (!moved) return;
		next.splice(target, 0, moved);
		onChange(next);
	};

	const resetQuickCreate = () => {
		setQuickName("");
		setQuickSlug("");
		setQuickError(null);
	};

	const openEditByline = (byline: BylineSummary) => {
		setEditBylineId(byline.id);
		setEditName(byline.displayName);
		setEditSlug(byline.slug);
		setEditError(null);
	};

	const resetQuickEdit = () => {
		setEditBylineId(null);
		setEditName("");
		setEditSlug("");
		setEditError(null);
	};

	// Multi-locale install with no bylines at the entry's locale: show a
	// CTA to the byline manager, scoped to that locale. Quick-create
	// still works inline.
	const isMultiLocale = !!i18n && i18n.locales.length > 1;
	const showLocaleEmptyState =
		isMultiLocale && bylinesLoaded && bylines.length === 0 && !!entryLocale;

	return (
		<div className="space-y-3">
			{showLocaleEmptyState && (
				<div className="rounded border border-dashed p-3 text-sm space-y-2">
					<p className="text-kumo-subtle">
						{t`No bylines available in ${entryLocale}. Create a variant from the Bylines page before crediting one on this entry.`}
					</p>
					<RouterLinkButton
						to="/bylines"
						search={{ locale: entryLocale ?? undefined }}
						variant="secondary"
						size="sm"
					>
						{t`Manage bylines in ${entryLocale}`}
					</RouterLinkButton>
				</div>
			)}
			<div className="space-y-2">
				<Input
					value={search}
					onChange={(e) => setSearch(e.target.value)}
					placeholder={t`Search bylines to add...`}
					aria-label={t`Search bylines`}
				/>
				{searchEnabled && searchResults.isLoading ? (
					<p className="text-sm text-kumo-subtle">{t`Searching...`}</p>
				) : availableToAdd.length > 0 ? (
					<ul className="max-h-48 divide-y overflow-y-auto rounded border">
						{availableToAdd.map((b) => (
							<li key={b.id}>
								<button
									type="button"
									className="flex w-full items-center justify-between gap-2 p-2 text-start hover:bg-kumo-tint"
									onClick={() => addByline(b.id)}
								>
									<span className="min-w-0">
										<span className="block truncate text-sm font-medium">{b.displayName}</span>
										<span className="block truncate text-xs text-kumo-subtle">{b.slug}</span>
									</span>
									<span className="text-xs text-kumo-subtle">{t`Add`}</span>
								</button>
							</li>
						))}
					</ul>
				) : searchEnabled && searchResults.isError ? (
					<p className="text-sm text-kumo-danger">{t`Couldn't search bylines. Please try again.`}</p>
				) : searchEnabled ? (
					<p className="text-sm text-kumo-subtle">{t`No matching bylines.`}</p>
				) : null}
				{hasMoreResults && (
					<p className="text-xs text-kumo-subtle">{t`Keep typing to narrow down more bylines.`}</p>
				)}
			</div>

			{credits.length > 0 ? (
				<div className="space-y-2">
					{credits.map((credit, index) => {
						const byline = bylineMap.get(credit.bylineId);
						if (!byline) return null;
						return (
							<div key={`${credit.bylineId}-${index}`} className="rounded border p-2 space-y-2">
								<div className="flex items-center justify-between gap-2">
									<div>
										<p className="text-sm font-medium">{byline.displayName}</p>
										<p className="text-xs text-kumo-subtle">{byline.slug}</p>
									</div>
									<div className="flex gap-1">
										<Button type="button" variant="ghost" size="sm" onClick={() => move(index, -1)}>
											{t`Up`}
										</Button>
										<Button type="button" variant="ghost" size="sm" onClick={() => move(index, 1)}>
											{t`Down`}
										</Button>
										{onQuickEdit && (
											<Button
												type="button"
												variant="ghost"
												size="sm"
												onClick={() => openEditByline(byline)}
											>
												{t`Edit`}
											</Button>
										)}
										<Button
											type="button"
											variant="destructive"
											size="sm"
											onClick={() => onChange(credits.filter((_, i) => i !== index))}
										>
											{t`Remove`}
										</Button>
									</div>
								</div>
								<Input
									label={t`Role label`}
									value={credit.roleLabel ?? ""}
									onChange={(e) => {
										const next = [...credits];
										const current = next[index];
										if (!current) return;
										next[index] = {
											...current,
											roleLabel: e.target.value || null,
										};
										onChange(next);
									}}
								/>
							</div>
						);
					})}
				</div>
			) : (
				<p className="text-sm text-kumo-subtle">{t`No bylines selected.`}</p>
			)}

			{onQuickCreate && (
				<Dialog.Root>
					<Dialog.Trigger
						render={(p) => (
							<Button {...p} type="button" variant="secondary">
								{t`Quick create byline`}
							</Button>
						)}
					/>
					<Dialog className="p-6" size="sm">
						<Dialog.Title className="text-lg font-semibold">{t`Create byline`}</Dialog.Title>
						<div className="mt-4 space-y-3">
							<Input
								label={t`Display name`}
								value={quickName}
								onChange={(e) => {
									setQuickName(e.target.value);
									if (!quickSlug) setQuickSlug(slugify(e.target.value));
								}}
							/>
							<Input
								label={t`Slug`}
								value={quickSlug}
								onChange={(e) => setQuickSlug(e.target.value)}
							/>
							{quickError && <p className="text-sm text-kumo-danger">{quickError}</p>}
						</div>
						<div className="mt-6 flex justify-end gap-2">
							<Dialog.Close
								render={(p) => (
									<Button
										{...p}
										variant="secondary"
										onClick={(e) => {
											resetQuickCreate();
											p.onClick?.(e);
										}}
									>
										{t`Cancel`}
									</Button>
								)}
							/>
							<Button
								type="button"
								disabled={!quickName || !quickSlug || isCreating}
								onClick={async () => {
									setQuickError(null);
									setIsCreating(true);
									try {
										const created = await onQuickCreate({
											displayName: quickName,
											slug: quickSlug,
										});
										onChange([...credits, { bylineId: created.id, roleLabel: null }]);
										resetQuickCreate();
									} catch (err) {
										setQuickError(err instanceof Error ? err.message : t`Failed to create byline`);
									} finally {
										setIsCreating(false);
									}
								}}
							>
								{isCreating ? t`Creating...` : t`Create`}
							</Button>
						</div>
					</Dialog>
				</Dialog.Root>
			)}

			{onQuickEdit && editBylineId && (
				<Dialog.Root open onOpenChange={(open) => (!open ? resetQuickEdit() : undefined)}>
					<Dialog className="p-6" size="sm">
						<Dialog.Title className="text-lg font-semibold">{t`Edit byline`}</Dialog.Title>
						<div className="mt-4 space-y-3">
							<Input
								label={t`Display name`}
								value={editName}
								onChange={(e) => {
									setEditName(e.target.value);
									if (!editSlug) setEditSlug(slugify(e.target.value));
								}}
							/>
							<Input
								label={t`Slug`}
								value={editSlug}
								onChange={(e) => setEditSlug(e.target.value)}
							/>
							{editError && <p className="text-sm text-kumo-danger">{editError}</p>}
						</div>
						<div className="mt-6 flex justify-end gap-2">
							<Button type="button" variant="secondary" onClick={resetQuickEdit}>
								{t`Cancel`}
							</Button>
							<Button
								type="button"
								disabled={!editName || !editSlug || isEditing}
								onClick={async () => {
									setEditError(null);
									setIsEditing(true);
									try {
										await onQuickEdit(editBylineId, {
											displayName: editName,
											slug: editSlug,
										});
										resetQuickEdit();
									} catch (err) {
										setEditError(err instanceof Error ? err.message : t`Failed to update byline`);
									} finally {
										setIsEditing(false);
									}
								}}
							>
								{isEditing ? t`Saving...` : t`Save`}
							</Button>
						</div>
					</Dialog>
				</Dialog.Root>
			)}
		</div>
	);
}

function AuthorSelector({ authorId, users, onChange }: AuthorSelectorProps) {
	const { t } = useLingui();
	const currentAuthor = users.find((u) => u.id === authorId);

	const authorItems: Record<string, string> = { unassigned: t`Unassigned` };
	for (const user of users) {
		authorItems[user.id] = user.name || user.email;
	}

	return (
		<div className="space-y-2">
			<Select
				value={authorId || "unassigned"}
				onValueChange={(value) =>
					onChange?.(value === "unassigned" || value === null ? null : value)
				}
				items={authorItems}
			>
				<Select.Option value="unassigned">
					<span className="text-kumo-subtle">{t`Unassigned`}</span>
				</Select.Option>
				{users.map((user) => (
					<Select.Option key={user.id} value={user.id}>
						<span className="flex items-center gap-2">
							{user.name || user.email}
							{user.name && <span className="text-xs text-kumo-subtle">({user.email})</span>}
						</span>
					</Select.Option>
				))}
			</Select>
			{currentAuthor && <p className="text-xs text-kumo-subtle">{currentAuthor.email}</p>}
		</div>
	);
}
