import type { ChangeEvent, CSSProperties, PointerEvent } from 'react';
import {
  Children,
  type ReactNode,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from 'react';
import { createPortal } from 'react-dom';

import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../../data/affinityLevels';
import {
  buildCustomWorldPlayableCharacters,
  buildCustomWorldRuntimeCharacters,
  createCharacterSkillCooldowns,
  getCharacterMaxHp,
  getCharacterMaxMana,
  ROLE_TEMPLATE_CHARACTERS,
  setRuntimeCharacterOverrides,
} from '../../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
import { normalizeCustomWorldLandmarks } from '../../data/customWorldSceneGraph';
import {
  getAllCustomWorldSceneImages,
  resolveCustomWorldCampSceneImage,
  resolveCustomWorldLandmarkImage,
} from '../../data/customWorldVisuals';
import { buildInitialEquipmentLoadout } from '../../data/equipmentEffects';
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
import { RESOLVED_ENTITY_X_METERS } from '../../data/sceneEncounterPreviews';
import { buildEncounterFromSceneNpc } from '../../data/scenePresets';
import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClient';
import { fetchJson } from '../../editor/shared/jsonClient';
import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story/useRpgRuntimeStory';
import { useRpgSessionBootstrap } from '../../hooks/rpg-session/useRpgSessionBootstrap';
import { useCombatFlow } from '../../hooks/useCombatFlow';
import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow';
import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts';
import type { CustomWorldSceneImageResult } from '../../services/aiTypes';
import { resolveCustomWorldCampScene } from '../../services/customWorldCamp';
import {
  buildDefaultCustomWorldCoverProfile,
  resolveCustomWorldCoverPresentation,
} from '../../services/customWorldCover';
import {
  type CustomWorldCoverAssetResult,
  generateCustomWorldCoverImage,
  uploadCustomWorldCoverImage,
} from '../../services/customWorldCoverAssetService';
import { createEmptyCustomWorldCreatorIntent } from '../../services/customWorldCreatorIntent';
import {
  type CustomWorldFoundationEntryId,
  getCustomWorldFoundationAnchorContent,
  parseFoundationTagText,
} from '../../services/customWorldFoundationEntries';
import {
  rpgCreationAssetClient,
  type RpgCreationHistoryAsset,
  type RpgCreationHistoryAssetKind,
} from '../../services/rpg-creation/rpgCreationAssetClient';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
  AnimationState,
  type Character,
  type CharacterAnimationConfig,
  type CustomWorldCoverCropRect,
  type CustomWorldCoverProfile,
  CustomWorldLandmark,
  CustomWorldNpc,
  CustomWorldPlayableNpc,
  CustomWorldProfile,
  type CustomWorldRoleInitialItem,
  type CustomWorldRoleRelation,
  type CustomWorldRoleSkill,
  CustomWorldSceneConnection,
  type ItemRarity,
  type SceneActAdvanceRule,
  type SceneActBlueprint,
  type SceneActStage,
  type SceneChapterBlueprint,
  type SceneNpc,
  type ScenePresetInfo,
  type StoryMoment,
  type StoryOption,
  WorldType,
} from '../../types';
import {
  type CharacterAnimationGenerationPayload,
  generateCharacterAnimationDraft,
  publishCharacterAnimationAssets,
} from '../asset-studio/characterAssetWorkflowPersistence';
import { useAuthUi } from '../auth/AuthUiContext';
import { CharacterAnimator } from '../CharacterAnimator';
import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformOverlayBadge } from '../common/PlatformOverlayBadge';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformSlotBadge } from '../common/PlatformSlotBadge';
import { PlatformStatusDialog } from '../common/PlatformStatusDialog';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
import { PlatformUploadTile } from '../common/PlatformUploadTile';
import { UnifiedConfirmDialog } from '../common/UnifiedConfirmDialog';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import { CustomWorldNpcPortrait } from '../CustomWorldNpcVisualEditor';
import {
  RoleCharacterSprite,
  SceneEncounterNpcSprite,
} from '../game-canvas/GameCanvasShared';
import { PixelIcon } from '../PixelIcon';
import { RpgCreationRoleAssetStudioModal } from '../rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal';
import { RpgRuntimeShell } from '../rpg-runtime-shell';
import {
  resolveEditableLandmark,
  resolveEditablePlayableNpc,
  resolveEditableStoryNpc,
} from './rpgCreationResultFormMapper';

export type RpgCreationEditorTarget =
  | { kind: 'world' }
  | { kind: 'foundation' }
  | { kind: 'cover' }
  | { kind: 'camp' }
  | { kind: 'playable'; mode: 'create' }
  | { kind: 'playable'; mode: 'edit'; id: string }
  | { kind: 'story'; mode: 'create' }
  | { kind: 'story'; mode: 'edit'; id: string }
  | { kind: 'landmark'; mode: 'create' }
  | { kind: 'landmark'; mode: 'edit'; id: string };

export interface RpgCreationEntityEditorModalProps {
  profile: CustomWorldProfile;
  target: RpgCreationEditorTarget | null;
  onClose: () => void;
  onProfileChange: (profile: CustomWorldProfile) => void;
}

function getAnimationPreviewFrameStyle(
  _config: CharacterAnimationConfig | null | undefined,
  targetSize: number,
) {
  return {
    width: `${targetSize}px`,
    height: `${targetSize}px`,
    maxWidth: '100%',
    maxHeight: '100%',
  } satisfies CSSProperties;
}

const [, , , BACKSTORY_UNLOCK_AFFINITY_CLOSE] =
  AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;

const ITEM_RARITY_OPTIONS: Array<{ value: ItemRarity; label: string }> = [
  { value: 'common', label: '普通' },
  { value: 'uncommon', label: '优秀' },
  { value: 'rare', label: '稀有' },
  { value: 'epic', label: '史诗' },
  { value: 'legendary', label: '传说' },
];

const ITEM_RARITY_LABELS: Record<ItemRarity, string> = {
  common: '普通',
  uncommon: '优秀',
  rare: '稀有',
  epic: '史诗',
  legendary: '传说',
};

const MIN_SCENE_ACT_COUNT = 2;
const MAX_SCENE_ACT_COUNT = 5;
const DEFAULT_SCENE_ACT_COUNT = 3;
const SCENE_ACT_ROLE_SLOT_COUNT = 3;
const RPG_EDITOR_STATUS_SUCCESS_CLASS_NAME =
  'rounded-2xl border-emerald-300/18 bg-emerald-500/10 text-emerald-50';
const RPG_EDITOR_STATUS_ERROR_CLASS_NAME =
  'rounded-2xl border-rose-400/18 bg-rose-500/10 text-rose-100';

function EditorEmptyState({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return (
    <PlatformEmptyState
      surface="editorDark"
      size="compact"
      tone="soft"
      className={['py-4 leading-normal', className].filter(Boolean).join(' ')}
    >
      {children}
    </PlatformEmptyState>
  );
}

function EditorInfoPanel({
  title,
  children,
  actions,
  className,
  headerClassName,
  actionsClassName,
  bodyClassName = 'mt-3',
}: {
  title: ReactNode;
  children: ReactNode;
  actions?: ReactNode;
  className?: string;
  headerClassName?: string;
  actionsClassName?: string;
  bodyClassName?: string;
}) {
  return (
    <PlatformSubpanel
      surface="dark"
      radius="md"
      padding="md"
      title={title}
      titleVariant="strong"
      actions={actions}
      headerClassName={headerClassName}
      titleClassName="text-[11px] font-bold tracking-[0.16em] text-zinc-300"
      actionsClassName={actionsClassName}
      bodyClassName={bodyClassName}
      className={className}
    >
      {children}
    </PlatformSubpanel>
  );
}

const CARDINAL_CONNECTION_DIRECTIONS = [
  'north',
  'east',
  'south',
  'west',
] as const;

type CardinalConnectionDirection =
  (typeof CARDINAL_CONNECTION_DIRECTIONS)[number];

const CARDINAL_CONNECTION_LABELS: Record<CardinalConnectionDirection, string> =
  {
    north: '北',
    east: '东',
    south: '南',
    west: '西',
  };

function slugify(value: string) {
  const normalized = value
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
    .replace(/^-+|-+$/g, '');

  return normalized || 'entry';
}

function createEntryId(prefix: string, label: string, seed: number) {
  return `${prefix}-${slugify(label || `${prefix}-${seed}`)}-${seed.toString(36)}`;
}

function parseCommaText(value: string) {
  return [
    ...new Set(
      value
        .split(/[\n,，]/u)
        .map((item) => item.trim())
        .filter(Boolean),
    ),
  ];
}

function dedupeTextValues(values: Array<string | null | undefined>) {
  return [
    ...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
  ];
}

function moveArrayItem<T>(values: T[], fromIndex: number, toIndex: number) {
  if (
    fromIndex < 0 ||
    toIndex < 0 ||
    fromIndex >= values.length ||
    toIndex >= values.length ||
    fromIndex === toIndex
  ) {
    return values;
  }

  const nextValues = [...values];
  const [movedItem] = nextValues.splice(fromIndex, 1);
  if (typeof movedItem === 'undefined') {
    return values;
  }

  nextValues.splice(toIndex, 0, movedItem);
  return nextValues;
}

function buildSceneActEncounterSlotIds(encounterNpcIds: string[]) {
  const compactIds = dedupeTextValues(encounterNpcIds).slice(
    0,
    SCENE_ACT_ROLE_SLOT_COUNT,
  );

  return Array.from(
    { length: SCENE_ACT_ROLE_SLOT_COUNT },
    (_unused, index) => compactIds[index] ?? null,
  ) as Array<string | null>;
}

function compactSceneActEncounterSlotIds(
  slotIds: Array<string | null | undefined>,
) {
  return dedupeTextValues(slotIds).slice(0, SCENE_ACT_ROLE_SLOT_COUNT);
}

function assignSceneActEncounterSlotId(
  encounterNpcIds: string[],
  slotIndex: number,
  nextNpcId: string | null,
) {
  const nextSlots = buildSceneActEncounterSlotIds(encounterNpcIds);
  const normalizedNpcId = nextNpcId?.trim() || null;

  if (slotIndex < 0 || slotIndex >= nextSlots.length) {
    return compactSceneActEncounterSlotIds(nextSlots);
  }

  if (!normalizedNpcId) {
    nextSlots.splice(slotIndex, 1);
    nextSlots.push(null);
    return compactSceneActEncounterSlotIds(nextSlots);
  }

  return compactSceneActEncounterSlotIds(
    nextSlots.map((slotNpcId, index) => {
      if (index === slotIndex) {
        return normalizedNpcId;
      }

      return slotNpcId === normalizedNpcId ? null : slotNpcId;
    }),
  );
}

function buildSceneActStageCoverage(
  index: number,
  actCount: number,
): SceneActStage[] {
  if (actCount <= 2) {
    return index === 0
      ? ['opening', 'expansion']
      : ['turning_point', 'climax', 'aftermath'];
  }

  if (actCount === 3) {
    if (index === 0) return ['opening'];
    if (index === 1) return ['expansion', 'turning_point'];
    return ['climax', 'aftermath'];
  }

  if (actCount === 4) {
    if (index === 0) return ['opening'];
    if (index === 1) return ['expansion'];
    if (index === 2) return ['turning_point'];
    return ['climax', 'aftermath'];
  }

  if (index === 0) return ['opening'];
  if (index === 1) return ['expansion'];
  if (index === 2) return ['turning_point'];
  if (index === 3) return ['climax'];
  return ['aftermath'];
}

function buildSceneActAdvanceRule(
  index: number,
  actCount: number,
): SceneActAdvanceRule {
  if (index === 0) {
    return 'after_primary_contact';
  }

  if (index >= actCount - 1) {
    return 'after_chapter_resolution';
  }

  return 'after_active_step_complete';
}

function buildDefaultSceneActTitle(index: number) {
  return `第${index + 1}幕`;
}

function buildDefaultSceneActBlueprint(params: {
  sceneId: string;
  sceneName: string;
  sceneSummary: string;
  encounterNpcIds: string[];
  backgroundImageSrc?: string | null;
  linkedThreadIds?: string[];
  index: number;
  actCount: number;
}): SceneActBlueprint {
  const encounterNpcIds = dedupeTextValues(params.encounterNpcIds).slice(0, 1);
  const actTitle = buildDefaultSceneActTitle(params.index);
  const sceneLabel = params.sceneName.trim() || '当前场景';
  const sceneSummary = params.sceneSummary.trim();
  const oppositeNpcId = encounterNpcIds[0] ?? '';
  const stageCoverage = buildSceneActStageCoverage(
    params.index,
    params.actCount,
  );
  const actSummary =
    params.index === 0
      ? `玩家会在${sceneLabel}接住这一章的开场入口。`
      : params.index >= params.actCount - 1
        ? `${sceneLabel}这一章会在这里把下一步方向抛给玩家。`
        : `${sceneLabel}的主要压力会在这一幕继续加深。`;

  return {
    id: `${params.sceneId}-act-${params.index + 1}`,
    sceneId: params.sceneId,
    title: actTitle,
    summary: actSummary,
    stageCoverage,
    // 幕背景画面描述应来自草稿生成阶段的大模型输出，前端缺失时只留空，避免展示规则拼接文本。
    backgroundPromptText: '',
    backgroundImageSrc: params.backgroundImageSrc || undefined,
    encounterNpcIds,
    primaryNpcId: oppositeNpcId,
    linkedThreadIds: dedupeTextValues(params.linkedThreadIds ?? []),
    advanceRule: buildSceneActAdvanceRule(params.index, params.actCount),
    oppositeNpcId,
    actGoal:
      params.index === 0
        ? `先在${sceneLabel}接住当前局面`
        : params.index >= params.actCount - 1
          ? `把${sceneLabel}这一章收束并抛出下一步`
          : `继续推进${sceneLabel}的核心矛盾`,
    eventDescription: buildDefaultSceneActEventDescription({
      sceneSummary,
      oppositeNpcId,
      index: params.index,
    }),
    transitionHook:
      params.index === 0
        ? '和主角色完成首次有效接触后，局势会继续加压。'
        : params.index >= params.actCount - 1
          ? '这一幕结束后，需要把后续方向明确抛给玩家。'
          : '完成当前主动推进后，这一幕会转向下一层压力。',
  };
}

function buildDefaultSceneActEventDescription(params: {
  sceneSummary: string;
  oppositeNpcId: string;
  index: number;
}) {
  const roleText = params.oppositeNpcId.trim() || '当前场景关键角色';
  const sceneText = params.sceneSummary.trim() || '场景内的主线压力';
  if (params.index === 0) {
    return `第1幕中，${roleText}先露出与${sceneText}有关的异常线索，玩家必须确认局势入口。`;
  }
  if (params.index === 1) {
    return `第2幕中，${roleText}的立场或阻碍让${sceneText}升级，玩家必须在压力下作出判断。`;
  }
  return `第3幕中，${roleText}把${sceneText}推向高潮，玩家必须面对关键抉择或直接后果。`;
}

function buildDefaultSceneActBackgroundPrompt(params: {
  sceneSummary: string;
  oppositeNpcId: string;
  eventDescription: string;
  index: number;
}) {
  const roleText = params.oppositeNpcId.trim() || '当前场景关键角色';
  const sceneText = params.sceneSummary.trim() || '场景内的主线压力';
  const phaseText =
    params.index === 0
      ? '铺垫阶段'
      : params.index === 1
        ? '冲突升级阶段'
        : '高潮阶段';
  return `${sceneText}的${phaseText}画面，${roleText}与玩家隔着可站立空间形成对峙，环境里保留“${params.eventDescription}”的冲突痕迹与清晰氛围。`;
}

function buildDefaultSceneTaskDescription(landmark: CustomWorldLandmark) {
  const sceneName = landmark.name.trim() || '当前场景';
  const sceneDescription = landmark.description.trim();
  if (!sceneDescription) {
    return `首次进入${sceneName}时，确认当前场景的核心异常、关键角色与下一步行动方向。`;
  }
  return `首次进入${sceneName}时，围绕${sceneDescription}确认核心任务、关键角色与下一步行动。`;
}

function buildDefaultSceneChapterBlueprint(params: {
  landmark: CustomWorldLandmark;
  fallbackImageSrc?: string | null;
  chapterId?: string;
  chapterTitle?: string;
  chapterSummary?: string;
  linkedThreadIds?: string[];
  linkedLandmarkIds?: string[];
  actCount?: number;
}) {
  const actCount = Math.min(
    MAX_SCENE_ACT_COUNT,
    Math.max(MIN_SCENE_ACT_COUNT, params.actCount ?? DEFAULT_SCENE_ACT_COUNT),
  );

  return {
    id: params.chapterId?.trim() || `scene-chapter-${params.landmark.id}`,
    sceneId: params.landmark.id,
    title:
      params.chapterTitle?.trim() || params.landmark.name.trim() || '场景章节',
    summary:
      params.chapterSummary?.trim() || params.landmark.description.trim(),
    sceneTaskDescription: buildDefaultSceneTaskDescription(params.landmark),
    linkedThreadIds: dedupeTextValues(params.linkedThreadIds ?? []),
    linkedLandmarkIds: dedupeTextValues([
      params.landmark.id,
      ...(params.linkedLandmarkIds ?? []),
    ]),
    acts: Array.from({ length: actCount }, (_unused, index) =>
      buildDefaultSceneActBlueprint({
        sceneId: params.landmark.id,
        sceneName: params.landmark.name,
        sceneSummary: params.landmark.description,
        encounterNpcIds: params.landmark.sceneNpcIds,
        backgroundImageSrc: params.fallbackImageSrc,
        linkedThreadIds: params.linkedThreadIds,
        index,
        actCount,
      }),
    ),
  } satisfies SceneChapterBlueprint;
}

function sanitizeSceneChapterBlueprint(params: {
  chapter: SceneChapterBlueprint | null | undefined;
  landmark: CustomWorldLandmark;
  fallbackImageSrc?: string | null;
}) {
  const fallbackChapter = buildDefaultSceneChapterBlueprint({
    landmark: params.landmark,
    fallbackImageSrc: params.fallbackImageSrc,
    chapterId: params.chapter?.id,
    chapterTitle: params.chapter?.title,
    chapterSummary: params.chapter?.summary,
    linkedThreadIds: params.chapter?.linkedThreadIds,
    linkedLandmarkIds: params.chapter?.linkedLandmarkIds,
    actCount: params.chapter?.acts.length,
  });
  const rawActs = params.chapter?.acts ?? [];
  const chapterEncounterNpcIds = dedupeTextValues(
    rawActs.flatMap((act) => act.encounterNpcIds),
  );
  const availableSceneNpcIds = dedupeTextValues([
    ...chapterEncounterNpcIds,
    ...params.landmark.sceneNpcIds,
  ]);
  const availableSceneNpcIdSet = new Set(availableSceneNpcIds);
  const targetActCount = Math.min(
    MAX_SCENE_ACT_COUNT,
    Math.max(
      MIN_SCENE_ACT_COUNT,
      rawActs.length > 0 ? rawActs.length : fallbackChapter.acts.length,
    ),
  );

  const acts = Array.from({ length: targetActCount }, (_unused, index) => {
    const fallbackAct = buildDefaultSceneActBlueprint({
      sceneId: params.landmark.id,
      sceneName: params.landmark.name,
      sceneSummary: params.landmark.description,
      encounterNpcIds: availableSceneNpcIds,
      backgroundImageSrc: params.fallbackImageSrc,
      linkedThreadIds:
        rawActs[index]?.linkedThreadIds ?? fallbackChapter.linkedThreadIds,
      index,
      actCount: targetActCount,
    });
    const currentAct = rawActs[index];
    const candidateNpcIds = dedupeTextValues(currentAct?.encounterNpcIds ?? []);
    const encounterNpcIds =
      availableSceneNpcIdSet.size > 0
        ? candidateNpcIds.filter((npcId) => availableSceneNpcIdSet.has(npcId))
        : candidateNpcIds;
    // 中文注释：已有幕只信任本幕保存的槽位；只有缺少整份幕蓝图的旧草稿才从场景角色里兜底，避免配置第一幕时把角色串到其他幕。
    const resolvedEncounterNpcIds =
      encounterNpcIds.length > 0
        ? encounterNpcIds
        : currentAct
          ? []
          : fallbackAct.encounterNpcIds;
    const primaryNpcId = resolvedEncounterNpcIds[0] ?? '';
    const oppositeNpcId = currentAct?.oppositeNpcId?.trim() || primaryNpcId;
    const eventDescription =
      currentAct?.eventDescription?.trim() ||
      buildDefaultSceneActEventDescription({
        sceneSummary: params.landmark.description,
        oppositeNpcId,
        index,
      });
    const backgroundPromptText =
      currentAct?.backgroundPromptText?.trim() ||
      buildDefaultSceneActBackgroundPrompt({
        sceneSummary: params.landmark.description,
        oppositeNpcId,
        eventDescription,
        index,
      });

    return {
      ...fallbackAct,
      id: currentAct?.id?.trim() || fallbackAct.id,
      title: currentAct?.title?.trim() || fallbackAct.title,
      summary: currentAct?.summary?.trim() || fallbackAct.summary,
      stageCoverage: buildSceneActStageCoverage(index, targetActCount),
      backgroundPromptText,
      backgroundImageSrc:
        currentAct?.backgroundImageSrc?.trim() ||
        params.fallbackImageSrc ||
        fallbackAct.backgroundImageSrc,
      encounterNpcIds: resolvedEncounterNpcIds,
      primaryNpcId,
      oppositeNpcId,
      eventDescription,
      linkedThreadIds: dedupeTextValues(currentAct?.linkedThreadIds ?? []),
      advanceRule: buildSceneActAdvanceRule(index, targetActCount),
      actGoal: currentAct?.actGoal?.trim() || fallbackAct.actGoal,
      transitionHook:
        currentAct?.transitionHook?.trim() || fallbackAct.transitionHook,
      backgroundAssetId:
        currentAct?.backgroundAssetId?.trim() || fallbackAct.backgroundAssetId,
    } satisfies SceneActBlueprint;
  });

  return {
    ...fallbackChapter,
    id: params.chapter?.id?.trim() || fallbackChapter.id,
    title: params.chapter?.title?.trim() || fallbackChapter.title,
    summary: params.chapter?.summary?.trim() || fallbackChapter.summary,
    sceneTaskDescription: (() => {
      const currentTask = params.chapter?.sceneTaskDescription?.trim() ?? '';
      const sceneDescription = params.landmark.description.trim();
      return currentTask && currentTask !== sceneDescription
        ? currentTask
        : fallbackChapter.sceneTaskDescription;
    })(),
    linkedThreadIds: dedupeTextValues(params.chapter?.linkedThreadIds ?? []),
    linkedLandmarkIds: dedupeTextValues([
      params.landmark.id,
      ...(params.chapter?.linkedLandmarkIds ?? []),
    ]),
    acts,
  } satisfies SceneChapterBlueprint;
}

function collectSceneChapterEncounterNpcIds(chapter: SceneChapterBlueprint) {
  return dedupeTextValues(chapter.acts.flatMap((act) => act.encounterNpcIds));
}

function resolveSceneCompatibilityNpcIds(params: {
  chapter: SceneChapterBlueprint;
  currentNpcIds: string[];
}) {
  const chapterNpcIds = collectSceneChapterEncounterNpcIds(params.chapter);
  return dedupeTextValues([...chapterNpcIds, ...params.currentNpcIds]);
}

function resolveSceneCompatibilityImageSrc(params: {
  chapter: SceneChapterBlueprint;
  currentImageSrc?: string | null;
  resolvedImageSrc?: string | null;
}) {
  const currentImageSrc = params.currentImageSrc?.trim() || '';
  const resolvedImageSrc = params.resolvedImageSrc?.trim() || '';
  const firstActImageSrc =
    params.chapter.acts[0]?.backgroundImageSrc?.trim() || '';

  // 中文注释：场景卡片只读取当前幕已保存图片；场景主图只给没有幕图的旧草稿兜底，不能反向覆盖每一幕。
  return firstActImageSrc || currentImageSrc || resolvedImageSrc || undefined;
}

function resolveSceneChapterBlueprintDraft(params: {
  profile: CustomWorldProfile;
  landmark: CustomWorldLandmark;
  fallbackImageSrc?: string | null;
}) {
  const matchedChapter =
    params.profile.sceneChapterBlueprints?.find(
      (entry) =>
        entry.sceneId === params.landmark.id ||
        entry.linkedLandmarkIds.includes(params.landmark.id),
    ) ?? null;

  return sanitizeSceneChapterBlueprint({
    chapter: matchedChapter,
    landmark: params.landmark,
    fallbackImageSrc: params.fallbackImageSrc,
  });
}

function upsertSceneChapterBlueprint(
  chapters: CustomWorldProfile['sceneChapterBlueprints'],
  nextChapter: SceneChapterBlueprint,
) {
  const nextChapters: SceneChapterBlueprint[] = [];
  let hasReplaced = false;

  (chapters ?? []).forEach((chapter) => {
    const isSameScene =
      chapter.id === nextChapter.id ||
      chapter.sceneId === nextChapter.sceneId ||
      chapter.linkedLandmarkIds.includes(nextChapter.sceneId);

    if (isSameScene) {
      if (!hasReplaced) {
        nextChapters.push(nextChapter);
        hasReplaced = true;
      }
      return;
    }

    nextChapters.push(chapter);
  });

  if (!hasReplaced) {
    nextChapters.push(nextChapter);
  }

  return nextChapters;
}

function normalizeConnectionDirection(
  value: CustomWorldSceneConnection['relativePosition'],
): CardinalConnectionDirection | null {
  switch (value) {
    case 'north':
    case 'forward':
      return 'north';
    case 'east':
    case 'right':
      return 'east';
    case 'south':
    case 'back':
      return 'south';
    case 'west':
    case 'left':
      return 'west';
    default:
      return null;
  }
}

function buildConnectionSummary(
  direction: CardinalConnectionDirection,
  targetName?: string,
) {
  if (!targetName) {
    return '';
  }
  return `${CARDINAL_CONNECTION_LABELS[direction]}侧可前往${targetName}`;
}

function buildDirectionalConnections(
  connections: CustomWorldSceneConnection[],
  landmarks: Array<Pick<CustomWorldLandmark, 'id' | 'name'>>,
) {
  const directionMap = new Map<
    CardinalConnectionDirection,
    CustomWorldSceneConnection
  >();

  connections.forEach((connection) => {
    const direction = normalizeConnectionDirection(connection.relativePosition);
    if (!direction || directionMap.has(direction)) {
      return;
    }

    const targetName =
      landmarks.find((entry) => entry.id === connection.targetLandmarkId)
        ?.name ?? '';
    directionMap.set(direction, {
      targetLandmarkId: connection.targetLandmarkId,
      relativePosition: direction,
      summary:
        connection.summary || buildConnectionSummary(direction, targetName),
    });
  });

  return CARDINAL_CONNECTION_DIRECTIONS.flatMap((direction) => {
    const connection = directionMap.get(direction);
    return connection ? [connection] : [];
  });
}

function commaText(value: string[]) {
  return value.join(', ');
}

function clampInitialAffinity(value: string, fallback: number) {
  const parsed = Number.parseInt(value, 10);
  if (!Number.isFinite(parsed)) {
    return fallback;
  }
  return Math.max(-40, Math.min(90, Math.round(parsed)));
}

function parseOptionalNumber(value: string) {
  const trimmed = value.trim();
  if (!trimmed) return undefined;
  const parsed = Number.parseInt(trimmed, 10);
  return Number.isFinite(parsed) ? parsed : undefined;
}

function hashText(value: string) {
  let hash = 0;
  for (let index = 0; index < value.length; index += 1) {
    hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
  }
  return hash;
}

function getItemRarityLabel(rarity: ItemRarity) {
  return ITEM_RARITY_LABELS[rarity] ?? '普通';
}

function getItemRarityCardClass(rarity: ItemRarity) {
  switch (rarity) {
    case 'legendary':
      return 'border-amber-400/45 bg-amber-500/10';
    case 'epic':
      return 'border-fuchsia-400/40 bg-fuchsia-500/10';
    case 'rare':
      return 'border-sky-400/40 bg-sky-500/10';
    case 'uncommon':
      return 'border-emerald-400/35 bg-emerald-500/10';
    default:
      return 'border-white/10 bg-black/20';
  }
}

function inferSkillActionTemplateId(
  skill: Pick<CustomWorldRoleSkill, 'name' | 'summary'>,
) {
  const source = `${skill.name} ${skill.summary}`;
  if (/[跑冲突进闪跃追袭位移]/u.test(source)) {
    return 'run';
  }
  if (/[受伤护体格挡防御硬扛]/u.test(source)) {
    return 'hurt';
  }
  if (/[终结死亡献祭同归于尽]/u.test(source)) {
    return 'die';
  }
  if (/[待机吟唱蓄力观察潜伏准备]/u.test(source)) {
    return 'idle';
  }
  return 'attack_slash';
}

function createRoleRelationDraft(
  seedLabel: string,
  index: number,
): CustomWorldRoleRelation {
  return {
    id: createEntryId('relation', seedLabel, Date.now() + index),
    targetRoleId: '',
    summary: '',
  };
}

function deriveRelationshipHooksFromRelations(
  relations: CustomWorldRoleRelation[],
) {
  return relations
    .map((item) => item.summary.trim())
    .filter(Boolean)
    .slice(0, 8);
}

function createRoleSkillDraft(seedLabel: string, index: number) {
  return {
    id: createEntryId('skill', seedLabel, Date.now() + index),
    name: `新技能${index + 1}`,
    summary: '',
    style: '起手压制',
    actionPromptText: '',
  };
}

function createRoleInitialItemDraft(seedLabel: string, index: number) {
  return {
    id: createEntryId('item', seedLabel, Date.now() + index),
    name: `新物品${index + 1}`,
    category: '材料',
    quantity: 1,
    rarity: 'rare' as ItemRarity,
    description: '',
    tags: [],
    iconSrc: undefined,
  };
}

function createBackstoryChapterDraft(seedLabel: string, index: number) {
  return {
    id: createEntryId('backstory-chapter', seedLabel, Date.now() + index),
    title: `背景片段${index + 1}`,
    affinityRequired:
      AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS[
        Math.min(index, AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.length - 1)
      ] ?? BACKSTORY_UNLOCK_AFFINITY_CLOSE,
    teaser: '',
    content: '',
    contextSnippet: '',
  };
}

function syncLandmarksWithStoryNpcs(
  landmarks: CustomWorldLandmark[],
  storyNpcs: CustomWorldProfile['storyNpcs'],
) {
  return normalizeCustomWorldLandmarks({
    landmarks,
    storyNpcs,
  });
}

type SceneActSelectableNpc = CustomWorldPlayableNpc | CustomWorldNpc;

function getSceneActSelectableNpcVisual(npc: SceneActSelectableNpc) {
  return 'visual' in npc ? npc.visual : undefined;
}

function buildSceneActSelectableNpcs(params: {
  profile: CustomWorldProfile;
  storyNpcs: CustomWorldProfile['storyNpcs'];
  preferredNpcIds: string[];
}) {
  const preferredNpcIdSet = new Set(params.preferredNpcIds);
  const dedupedNpcs = new Map<string, SceneActSelectableNpc>();

  [...params.profile.playableNpcs, ...params.storyNpcs].forEach((npc) => {
    const npcId = npc.id.trim();
    if (!npcId || dedupedNpcs.has(npcId)) {
      return;
    }
    dedupedNpcs.set(npcId, npc);
  });

  return [...dedupedNpcs.values()].sort((left, right) => {
    const leftPreferred = preferredNpcIdSet.has(left.id) ? 0 : 1;
    const rightPreferred = preferredNpcIdSet.has(right.id) ? 0 : 1;
    if (leftPreferred !== rightPreferred) {
      return leftPreferred - rightPreferred;
    }
    return left.name.localeCompare(right.name, 'zh-Hans-CN');
  });
}

function buildDraftSyncToken(value: unknown) {
  try {
    const serialized = JSON.stringify(value);
    return serialized ?? 'undefined';
  } catch {
    return String(value);
  }
}

function useDraft<T>(value: T) {
  const syncToken = useMemo(() => buildDraftSyncToken(value), [value]);
  const [draft, setDraft] = useState(value);
  const lastSyncedTokenRef = useRef(syncToken);

  useEffect(() => {
    if (lastSyncedTokenRef.current === syncToken) {
      return;
    }

    lastSyncedTokenRef.current = syncToken;
    setDraft(value);
  }, [syncToken, value]);

  return [draft, setDraft] as const;
}

function readImageFileAsDataUrl(file: File) {
  return new Promise<string>((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(String(reader.result ?? ''));
    reader.onerror = () => reject(reader.error ?? new Error('读取图片失败。'));
    reader.readAsDataURL(file);
  });
}

function loadImageDimensionsFromDataUrl(source: string) {
  return new Promise<{ width: number; height: number }>((resolve, reject) => {
    const image = new Image();
    image.onload = () => {
      resolve({
        width: image.naturalWidth,
        height: image.naturalHeight,
      });
    };
    image.onerror = () => reject(new Error('读取图片尺寸失败。'));
    image.src = source;
  });
}

const COVER_CROP_RATIO = 16 / 9;

type CoverCropDragHandle =
  | 'move'
  | 'north'
  | 'northEast'
  | 'east'
  | 'southEast'
  | 'south'
  | 'southWest'
  | 'west'
  | 'northWest';

type CoverCropDragSnapshot = {
  pointerId: number;
  handle: CoverCropDragHandle;
  clientX: number;
  clientY: number;
  cropRect: CustomWorldCoverCropRect;
  previewWidth: number;
  previewHeight: number;
};

const COVER_CROP_RESIZE_HANDLES: Array<{
  handle: Exclude<CoverCropDragHandle, 'move'>;
  label: string;
  className: string;
  dotClassName: string;
}> = [
  {
    handle: 'northWest',
    label: '拖拽左上角裁剪边界',
    className:
      'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize',
    dotClassName: 'left-1/2 top-1/2',
  },
  {
    handle: 'north',
    label: '拖拽上边裁剪边界',
    className:
      'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize',
    dotClassName: 'left-1/2 top-1/2',
  },
  {
    handle: 'northEast',
    label: '拖拽右上角裁剪边界',
    className:
      'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize',
    dotClassName: 'left-1/2 top-1/2',
  },
  {
    handle: 'east',
    label: '拖拽右边裁剪边界',
    className:
      'right-0 top-1/2 -translate-y-1/2 translate-x-1/2 cursor-ew-resize',
    dotClassName: 'left-1/2 top-1/2',
  },
  {
    handle: 'southEast',
    label: '拖拽右下角裁剪边界',
    className:
      'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize',
    dotClassName: 'left-1/2 top-1/2',
  },
  {
    handle: 'south',
    label: '拖拽下边裁剪边界',
    className:
      'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 cursor-ns-resize',
    dotClassName: 'left-1/2 top-1/2',
  },
  {
    handle: 'southWest',
    label: '拖拽左下角裁剪边界',
    className:
      'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize',
    dotClassName: 'left-1/2 top-1/2',
  },
  {
    handle: 'west',
    label: '拖拽左边裁剪边界',
    className:
      'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize',
    dotClassName: 'left-1/2 top-1/2',
  },
];

function clampNumber(value: number, min: number, max: number) {
  return Math.max(min, Math.min(max, value));
}

function getCoverCropSizeBounds(imageSize: { width: number; height: number }) {
  const maxWidth = Math.max(
    1,
    Math.min(imageSize.width, imageSize.height * COVER_CROP_RATIO),
  );
  const minWidth = Math.min(maxWidth, Math.max(48, maxWidth * 0.16));

  return { minWidth, maxWidth };
}

function normalizeCoverCropRect(
  cropRect: CustomWorldCoverCropRect,
  imageSize: { width: number; height: number },
): CustomWorldCoverCropRect {
  const { minWidth, maxWidth } = getCoverCropSizeBounds(imageSize);
  const width = clampNumber(cropRect.width, minWidth, maxWidth);
  const height = width / COVER_CROP_RATIO;
  const x = clampNumber(cropRect.x, 0, Math.max(0, imageSize.width - width));
  const y = clampNumber(cropRect.y, 0, Math.max(0, imageSize.height - height));

  return { x, y, width, height };
}

function buildCenteredCoverCropRect(
  width: number,
  height: number,
): CustomWorldCoverCropRect {
  if (width <= 0 || height <= 0) {
    return { x: 0, y: 0, width: 1, height: 1 };
  }

  if (width / height >= COVER_CROP_RATIO) {
    const cropHeight = height;
    const cropWidth = cropHeight * COVER_CROP_RATIO;
    return {
      x: (width - cropWidth) / 2,
      y: 0,
      width: cropWidth,
      height: cropHeight,
    };
  }

  const cropWidth = width;
  const cropHeight = cropWidth / COVER_CROP_RATIO;
  return {
    x: 0,
    y: (height - cropHeight) / 2,
    width: cropWidth,
    height: cropHeight,
  };
}

function resizeCoverCropRectFromHandle(
  snapshot: CoverCropDragSnapshot,
  deltaX: number,
  deltaY: number,
  imageSize: { width: number; height: number },
): CustomWorldCoverCropRect {
  const start = snapshot.cropRect;
  const startRight = start.x + start.width;
  const startBottom = start.y + start.height;
  const startCenterX = start.x + start.width / 2;
  const startCenterY = start.y + start.height / 2;
  const { minWidth, maxWidth } = getCoverCropSizeBounds(imageSize);
  const chooseWidth = (widthFromX: number, widthFromY: number) => {
    const xDistance = Math.abs(widthFromX - start.width);
    const yDistance = Math.abs(widthFromY - start.width);

    return xDistance >= yDistance ? widthFromX : widthFromY;
  };
  const clampWidth = (width: number, maxByAnchor = maxWidth) =>
    clampNumber(
      width,
      minWidth,
      Math.max(minWidth, Math.min(maxWidth, maxByAnchor)),
    );

  if (snapshot.handle === 'move') {
    return normalizeCoverCropRect(
      {
        ...start,
        x: start.x + deltaX,
        y: start.y + deltaY,
      },
      imageSize,
    );
  }

  if (snapshot.handle === 'east' || snapshot.handle === 'west') {
    const isEast = snapshot.handle === 'east';
    const anchorX = isEast ? start.x : startRight;
    const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
    const maxByCenterY =
      2 *
      Math.min(startCenterY, imageSize.height - startCenterY) *
      COVER_CROP_RATIO;
    const width = clampWidth(
      start.width + (isEast ? deltaX : -deltaX),
      Math.min(maxByAnchorX, maxByCenterY),
    );
    const height = width / COVER_CROP_RATIO;

    return normalizeCoverCropRect(
      {
        x: isEast ? anchorX : anchorX - width,
        y: startCenterY - height / 2,
        width,
        height,
      },
      imageSize,
    );
  }

  if (snapshot.handle === 'north' || snapshot.handle === 'south') {
    const isSouth = snapshot.handle === 'south';
    const anchorY = isSouth ? start.y : startBottom;
    const maxByAnchorY =
      (isSouth ? imageSize.height - anchorY : anchorY) * COVER_CROP_RATIO;
    const maxByCenterX =
      2 * Math.min(startCenterX, imageSize.width - startCenterX);
    const width = clampWidth(
      (start.height + (isSouth ? deltaY : -deltaY)) * COVER_CROP_RATIO,
      Math.min(maxByAnchorY, maxByCenterX),
    );
    const height = width / COVER_CROP_RATIO;

    return normalizeCoverCropRect(
      {
        x: startCenterX - width / 2,
        y: isSouth ? anchorY : anchorY - height,
        width,
        height,
      },
      imageSize,
    );
  }

  const isEast =
    snapshot.handle === 'northEast' || snapshot.handle === 'southEast';
  const isSouth =
    snapshot.handle === 'southEast' || snapshot.handle === 'southWest';
  const anchorX = isEast ? start.x : startRight;
  const anchorY = isSouth ? start.y : startBottom;
  const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
  const maxByAnchorY =
    (isSouth ? imageSize.height - anchorY : anchorY) * COVER_CROP_RATIO;
  const widthFromX = start.width + (isEast ? deltaX : -deltaX);
  const widthFromY =
    (start.height + (isSouth ? deltaY : -deltaY)) * COVER_CROP_RATIO;
  const width = clampWidth(
    chooseWidth(widthFromX, widthFromY),
    Math.min(maxByAnchorX, maxByAnchorY),
  );
  const height = width / COVER_CROP_RATIO;

  return normalizeCoverCropRect(
    {
      x: isEast ? anchorX : anchorX - width,
      y: isSouth ? anchorY : anchorY - height,
      width,
      height,
    },
    imageSize,
  );
}

function buildCoverCropPreviewStyle(
  cropRect: CustomWorldCoverCropRect,
  imageSize: { width: number; height: number },
) {
  if (imageSize.width <= 0 || imageSize.height <= 0) {
    return {};
  }

  return {
    left: `${(cropRect.x / imageSize.width) * 100}%`,
    top: `${(cropRect.y / imageSize.height) * 100}%`,
    width: `${(cropRect.width / imageSize.width) * 100}%`,
    height: `${(cropRect.height / imageSize.height) * 100}%`,
  } satisfies CSSProperties;
}

function ModalShell({
  title,
  subtitle,
  onClose,
  children,
  panelClassName = 'sm:max-w-2xl',
  overlayClassName = 'z-[98]',
  bodyClassName = '',
  disableClose = false,
  usePixelFont = false,
}: {
  title: string;
  subtitle?: string;
  onClose: () => void;
  children: ReactNode;
  panelClassName?: string;
  overlayClassName?: string;
  bodyClassName?: string;
  disableClose?: boolean;
  usePixelFont?: boolean;
}) {
  const authUi = useAuthUi();
  const platformThemeClass =
    authUi?.platformTheme === 'dark'
      ? 'platform-theme--dark'
      : 'platform-theme--light';

  return (
    <div
      className={`platform-overlay fixed inset-0 ${overlayClassName} flex items-end justify-center p-0 backdrop-blur-sm sm:items-center sm:p-4`}
      onClick={
        disableClose
          ? undefined
          : (event) => {
              if (event.target === event.currentTarget) {
                onClose();
              }
            }
      }
    >
      <div
        className={`platform-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] xl:max-h-[min(94vh,64rem)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`} ${panelClassName} sm:rounded-[1.75rem]`}
        onClick={(event) => event.stopPropagation()}
      >
        <div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4 sm:px-5">
          <div className="min-w-0">
            <div className="truncate text-sm font-semibold text-white">
              {title}
            </div>
            {subtitle ? (
              <div className="mt-1 text-xs leading-6 text-zinc-400">
                {subtitle}
              </div>
            ) : null}
          </div>
          <PlatformModalCloseButton
            label="关闭"
            variant="platformIcon"
            onClick={onClose}
            disabled={disableClose}
            className={disableClose ? 'cursor-not-allowed opacity-45' : ''}
          />
        </div>
        <div
          className={`min-h-0 flex-1 overflow-y-auto p-4 sm:p-5 ${bodyClassName}`}
        >
          {children}
        </div>
      </div>
    </div>
  );
}

function _PortalModalShell(props: {
  title: string;
  subtitle?: string;
  onClose: () => void;
  children: ReactNode;
  panelClassName?: string;
  overlayClassName?: string;
  bodyClassName?: string;
  disableClose?: boolean;
  usePixelFont?: boolean;
}) {
  if (typeof document === 'undefined') {
    return null;
  }

  return createPortal(<ModalShell {...props} />, document.body);
}

function CompactDialogShell({
  title,
  onClose,
  children,
  panelClassName = '',
  overlayClassName = 'z-[140]',
  disableClose = false,
  usePixelFont = false,
}: {
  title: string;
  onClose: () => void;
  children: ReactNode;
  panelClassName?: string;
  overlayClassName?: string;
  disableClose?: boolean;
  usePixelFont?: boolean;
}) {
  const authUi = useAuthUi();
  const platformThemeClass =
    authUi?.platformTheme === 'dark'
      ? 'platform-theme--dark'
      : 'platform-theme--light';

  return (
    <div
      className={`platform-overlay fixed inset-0 ${overlayClassName} flex items-center justify-center p-4 backdrop-blur-sm`}
      onClick={
        disableClose
          ? undefined
          : (event) => {
              if (event.target === event.currentTarget) {
                onClose();
              }
            }
      }
    >
      <div
        className={`platform-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`} ${panelClassName}`}
        onClick={(event) => event.stopPropagation()}
      >
        <div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4">
          <div className="min-w-0 text-sm font-semibold text-white">
            {title}
          </div>
          <PlatformModalCloseButton
            label="关闭"
            variant="platformIcon"
            onClick={onClose}
            disabled={disableClose}
            className={disableClose ? 'cursor-not-allowed opacity-45' : ''}
          />
        </div>
        <div className="p-4">{children}</div>
      </div>
    </div>
  );
}

function PortalCompactDialogShell(props: {
  title: string;
  onClose: () => void;
  children: ReactNode;
  panelClassName?: string;
  overlayClassName?: string;
  disableClose?: boolean;
  usePixelFont?: boolean;
}) {
  if (typeof document === 'undefined') {
    return null;
  }

  return createPortal(<CompactDialogShell {...props} />, document.body);
}

function CloseConfirmDialog({
  message,
  onCancel,
  onConfirm,
  confirmLabel = '确认关闭',
}: {
  message: string;
  onCancel: () => void;
  onConfirm: () => void;
  confirmLabel?: string;
}) {
  return (
    <UnifiedConfirmDialog
      open
      title="确认关闭"
      onClose={onCancel}
      confirmLabel={confirmLabel}
      cancelLabel="继续编辑"
      showCancel
      onConfirm={onConfirm}
      overlayClassName="z-[140] !items-center"
      panelClassName="platform-remap-surface rounded-[1.5rem]"
    >
      {message}
    </UnifiedConfirmDialog>
  );
}

function EditorNoticeDialog({
  message,
  onClose,
}: {
  message: string;
  onClose: () => void;
}) {
  return (
    <PlatformStatusDialog
      status="error"
      title="提示"
      description={message}
      onClose={onClose}
      showHeader
      showCloseButton
      closeOnBackdrop
      closeOnEscape
      action={{
        label: '知道了',
        onClick: onClose,
        surface: 'platform',
        size: 'sm',
        fullWidth: false,
        className: 'min-h-0 rounded-full px-4 py-2',
      }}
      zIndexClassName="z-[140]"
      overlayClassName="rpg-editor-notice-dialog-overlay !items-center"
      panelClassName="platform-remap-surface rounded-[1.5rem]"
    />
  );
}

function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
  const hasVisibleChildren = Children.toArray(children).some(
    (child) => !(typeof child === 'string' && child.trim().length === 0),
  );
  if (!hasVisibleChildren) return null;

  return (
    <label className="block">
      <div className="mb-2 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
        {label}
      </div>
      {children}
    </label>
  );
}

function LabelWithInfo({ label, info }: { label: string; info: string }) {
  const [open, setOpen] = useState(false);

  return (
    <span className="flex flex-wrap items-center gap-2">
      <span>{label}</span>
      <PlatformIconButton
        label={`${label}说明`}
        variant="darkMini"
        icon={<span aria-hidden="true">?</span>}
        onClick={(event) => {
          event.preventDefault();
          setOpen((current) => !current);
        }}
        className="h-4 w-4 text-[10px]"
      />
      {open ? (
        <span className="w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-[11px] font-normal tracking-normal text-zinc-300">
          {info}
        </span>
      ) : null}
    </span>
  );
}

function TextInput({
  value,
  onChange,
  type = 'text',
  placeholder,
}: {
  value: string | number;
  onChange: (value: string) => void;
  type?: 'text' | 'number';
  placeholder?: string;
}) {
  return (
    <input
      type={type}
      value={value}
      placeholder={placeholder}
      onChange={(event) => onChange(event.target.value)}
      className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
    />
  );
}

function TextArea({
  value,
  onChange,
  rows = 4,
  placeholder,
}: {
  value: string;
  onChange: (value: string) => void;
  rows?: number;
  placeholder?: string;
}) {
  return (
    <textarea
      rows={rows}
      value={value}
      placeholder={placeholder}
      onChange={(event) => onChange(event.target.value)}
      className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
    />
  );
}

function SelectField({
  value,
  onChange,
  options,
}: {
  value: string;
  onChange: (value: string) => void;
  options: Array<{ value: string; label: string }>;
}) {
  return (
    <select
      value={value}
      onChange={(event) => onChange(event.target.value)}
      className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors focus:border-sky-300/35"
    >
      {options.map((option) => (
        <option key={`${option.value}-${option.label}`} value={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
}

function ImagePreview({
  src,
  alt,
  fallbackLabel,
  tone = 'square',
  children,
  previewOverlay,
  overlayInteractive = false,
}: {
  src?: string;
  alt: string;
  fallbackLabel: string;
  tone?: 'square' | 'landscape';
  children?: ReactNode;
  previewOverlay?: ReactNode;
  overlayInteractive?: boolean;
}) {
  return (
    <PlatformMediaFrame
      src={src}
      alt={alt}
      fallbackLabel={fallbackLabel}
      aspect={tone === 'landscape' ? 'landscape' : 'square'}
      surface="editorDark"
      loading="lazy"
      overlayInteractive={overlayInteractive}
      previewOverlay={previewOverlay}
    >
      {children}
    </PlatformMediaFrame>
  );
}

function ActionButton({
  label,
  onClick,
  tone = 'default',
  disabled = false,
  className = '',
}: {
  label: string;
  onClick: () => void;
  tone?: 'default' | 'sky' | 'rose';
  disabled?: boolean;
  className?: string;
}) {
  const toneClassName =
    tone === 'sky'
      ? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
      : tone === 'rose'
        ? 'border-rose-300/22 bg-rose-500/12 text-rose-50 hover:border-rose-200/40 hover:text-white'
        : 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white';

  return (
    <button
      type="button"
      onPointerDown={(event) => {
        event.stopPropagation();
      }}
      onMouseDown={(event) => {
        event.stopPropagation();
      }}
      onClick={(event) => {
        event.stopPropagation();
        onClick();
      }}
      disabled={disabled}
      className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''} ${className}`}
    >
      {label}
    </button>
  );
}

function formatHistoryAssetDate(value: string) {
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) {
    return value || '';
  }
  return date.toLocaleString('zh-CN', {
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  });
}

function HistoryAssetPickerModal({
  title,
  kind,
  tone,
  onSelect,
  onClose,
}: {
  title: string;
  kind: RpgCreationHistoryAssetKind;
  tone: 'square' | 'landscape';
  onSelect: (asset: RpgCreationHistoryAsset) => void;
  onClose: () => void;
}) {
  const [assets, setAssets] = useState<RpgCreationHistoryAsset[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let isCancelled = false;
    setIsLoading(true);
    setError(null);
    rpgCreationAssetClient
      .listHistoryAssets({ kind, limit: 120 })
      .then((nextAssets) => {
        if (!isCancelled) {
          setAssets(nextAssets);
        }
      })
      .catch((loadError) => {
        if (!isCancelled) {
          setError(
            loadError instanceof Error
              ? loadError.message
              : '历史素材读取失败。',
          );
        }
      })
      .finally(() => {
        if (!isCancelled) {
          setIsLoading(false);
        }
      });

    return () => {
      isCancelled = true;
    };
  }, [kind]);

  return (
    <ModalShell
      title={title}
      onClose={onClose}
      overlayClassName="z-[99]"
      panelClassName="sm:max-w-5xl"
    >
      <div className="space-y-4">
        <PlatformAssetPickerGrid
          items={assets}
          isLoading={isLoading}
          error={error}
          loadingLabel="读取中..."
          emptyLabel="暂无历史素材"
          surface="editorDark"
          selectLabel="使用"
          getKey={(asset) => asset.assetObjectId}
          getImageSrc={(asset) => asset.imageSrc}
          getImageAlt={(asset) => asset.ownerLabel || '素材'}
          getTitle={(asset) => asset.ownerLabel || '未记录账号'}
          getSubtitle={(asset) => formatHistoryAssetDate(asset.createdAt)}
          onSelect={onSelect}
          gridClassName={`grid gap-3 ${
            tone === 'landscape'
              ? 'sm:grid-cols-2 xl:grid-cols-3'
              : 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-4'
          }`}
          imageShellClassName={
            tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'
          }
          bodyClassName="space-y-2 px-3 py-3"
        />
      </div>
    </ModalShell>
  );
}

const SCENE_ACT_SLOT_LAYOUTS = [
  {
    left: '77%',
    bottom: '16%',
    scale: 1,
    zIndex: 4,
  },
  {
    left: '91%',
    bottom: '36%',
    scale: 0.82,
    zIndex: 3,
  },
  {
    left: '91%',
    bottom: '4%',
    scale: 0.82,
    zIndex: 2,
  },
] as const;

const SCENE_ACT_PLAYER_LAYOUT = {
  left: '24%',
  bottom: '11%',
  scale: 1,
  zIndex: 4,
} as const;

function SceneActStageNpcSprite({
  npc,
  slotIndex,
  onClick,
}: {
  npc: CustomWorldNpc | null;
  slotIndex: number;
  onClick: () => void;
}) {
  const slotBadgeLabel = slotIndex === 0 ? '主' : String(slotIndex + 1);
  const slotName = npc?.name ?? '添加角色';
  const previewEncounter = npc
    ? buildEncounterFromSceneNpc(
        buildSceneActPreviewSceneNpc(npc),
        RESOLVED_ENTITY_X_METERS,
      )
    : null;

  return (
    <button
      type="button"
      onClick={onClick}
      data-testid="scene-act-slot-button"
      aria-label={
        npc
          ? `配置第${slotIndex + 1}个角色：${npc.name}`
          : `为第${slotIndex + 1}个角色添加角色`
      }
      className="group relative flex flex-col items-center text-center transition-transform hover:scale-[1.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80"
      style={{ transformOrigin: 'center bottom' }}
    >
      <div
        className={`mb-1 flex max-w-[6.5rem] items-center gap-1 rounded-full border px-2 py-1 shadow-[0_8px_18px_rgba(0,0,0,0.32)] backdrop-blur-sm ${
          npc
            ? 'border-sky-100/70 bg-slate-950/78 text-white'
            : 'border-white/38 bg-white/86 text-zinc-800'
        }`}
      >
        <PlatformSlotBadge tone={npc ? 'active' : 'inactive'}>
          {slotBadgeLabel}
        </PlatformSlotBadge>
        <span className="truncate text-[11px] font-bold leading-none">
          {slotName}
        </span>
      </div>
      <div
        className={`relative flex h-[4.8rem] w-[4.2rem] items-end justify-center overflow-visible transition-all sm:h-[5.4rem] sm:w-[4.8rem] ${
          npc
            ? 'drop-shadow-[0_8px_16px_rgba(0,0,0,0.34)]'
            : 'opacity-95 drop-shadow-[0_8px_16px_rgba(0,0,0,0.24)]'
        }`}
      >
        {previewEncounter ? (
          <SceneEncounterNpcSprite
            encounter={previewEncounter}
            state={AnimationState.IDLE}
            facing="left"
          />
        ) : (
          <div className="flex h-[4rem] w-[2.8rem] items-center justify-center rounded-[999px] border border-dashed border-white/65 bg-slate-950/42 text-lg font-black text-white shadow-[inset_0_0_18px_rgba(255,255,255,0.08)] sm:h-[4.5rem] sm:w-[3.1rem]">
            +
          </div>
        )}
      </div>
    </button>
  );
}

function SceneActStagePlayerSprite({
  character,
}: {
  character: Character | null;
}) {
  if (!character) {
    return null;
  }

  return (
    <div
      className="pointer-events-none flex flex-col items-center text-center"
      style={{ transformOrigin: 'center bottom' }}
    >
      <div className="mb-1 max-w-[6rem] truncate text-[11px] font-medium leading-5 text-zinc-100 [text-shadow:0_2px_10px_rgba(0,0,0,0.82)]">
        {character.name}
      </div>
      <div className="relative flex h-[4.9rem] w-[4.2rem] items-end justify-center overflow-visible drop-shadow-[0_8px_16px_rgba(0,0,0,0.34)] sm:h-[5.5rem] sm:w-[4.8rem]">
        <RoleCharacterSprite
          state={AnimationState.IDLE}
          character={character}
          facing="right"
        />
      </div>
    </div>
  );
}

function SceneActStagePreview({
  actLabel,
  imageSrc,
  fallbackImageSrc,
  previewCharacter,
  slotNpcs,
  onSlotClick,
}: {
  actLabel: string;
  imageSrc?: string | null;
  fallbackImageSrc?: string | null;
  previewCharacter: Character | null;
  slotNpcs: Array<CustomWorldNpc | null>;
  onSlotClick: (slotIndex: number) => void;
}) {
  return (
    <ImagePreview
      src={imageSrc || fallbackImageSrc || undefined}
      alt={`${actLabel}幕背景`}
      fallbackLabel={actLabel}
      tone="landscape"
      overlayInteractive
    >
      <div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,17,0.12)_0%,rgba(8,10,17,0.28)_45%,rgba(8,10,17,0.78)_100%)]" />
      <PlatformOverlayBadge>{actLabel}</PlatformOverlayBadge>
      <div className="absolute inset-x-0 bottom-0 h-24 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.74)_100%)]" />
      <div className="absolute bottom-[12%] left-[50%] h-16 w-16 -translate-x-1/2 rounded-full bg-amber-300/18 blur-2xl sm:h-20 sm:w-20" />
      <div
        className="absolute"
        style={{
          left: SCENE_ACT_PLAYER_LAYOUT.left,
          bottom: SCENE_ACT_PLAYER_LAYOUT.bottom,
          zIndex: SCENE_ACT_PLAYER_LAYOUT.zIndex,
          transform: `translateX(-50%) scale(${SCENE_ACT_PLAYER_LAYOUT.scale})`,
          transformOrigin: 'center bottom',
        }}
      >
        <SceneActStagePlayerSprite character={previewCharacter} />
      </div>
      {slotNpcs.map((npc, slotIndex) => {
        const layout = SCENE_ACT_SLOT_LAYOUTS[slotIndex];
        if (!layout) {
          return null;
        }

        return (
          <div
            key={`${actLabel}-slot-${slotIndex + 1}`}
            className="absolute"
            style={{
              left: layout.left,
              bottom: layout.bottom,
              zIndex: layout.zIndex,
              transform: `translateX(-50%) scale(${layout.scale})`,
              transformOrigin: 'center bottom',
            }}
          >
            <SceneActStageNpcSprite
              slotIndex={slotIndex}
              npc={npc}
              onClick={() => onSlotClick(slotIndex)}
            />
          </div>
        );
      })}
    </ImagePreview>
  );
}

function SceneActNpcSlotPickerModal({
  actLabel,
  slotIndex,
  currentNpcId,
  availableNpcs,
  onNotice,
  onApply,
  onClose,
}: {
  actLabel: string;
  slotIndex: number;
  currentNpcId?: string | null;
  availableNpcs: SceneActSelectableNpc[];
  onNotice: (message: string) => void;
  onApply: (npcId: string | null) => void;
  onClose: () => void;
}) {
  const [draftNpcId, setDraftNpcId] = useState(currentNpcId?.trim() || '');
  const selectedNpc =
    availableNpcs.find((npc) => npc.id === draftNpcId) ??
    availableNpcs.find((npc) => npc.id === currentNpcId) ??
    null;
  const slotLabel =
    slotIndex === 0 ? '主角色槽位' : `角色槽位 ${slotIndex + 1}`;

  useEffect(() => {
    setDraftNpcId(currentNpcId?.trim() || '');
  }, [currentNpcId]);

  return (
    <ModalShell
      title={`配置角色：${actLabel} · ${slotLabel}`}
      onClose={onClose}
      panelClassName="flex max-h-[88vh] flex-col sm:max-w-4xl"
      bodyClassName="flex flex-col overflow-hidden"
    >
      <div className="flex min-h-0 flex-1 flex-col">
        <div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
          <EditorInfoPanel title="当前角色">
            {selectedNpc ? (
              <div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
                <div className="flex items-center gap-3">
                  <CustomWorldNpcPortrait
                    npc={selectedNpc}
                    visual={getSceneActSelectableNpcVisual(selectedNpc)}
                    className="h-20 w-20 shrink-0"
                    contentClassName="min-h-0 p-2"
                    scale={1.75}
                    preferImageSrc
                  />
                  <div className="min-w-0">
                    <div className="text-sm font-semibold text-white">
                      {selectedNpc.name}
                    </div>
                    <div className="mt-1 text-xs leading-6 text-zinc-400">
                      {selectedNpc.title || selectedNpc.role || '未填写身份'}
                    </div>
                  </div>
                </div>
              </div>
            ) : (
              <EditorEmptyState>当前槽位还没有角色。</EditorEmptyState>
            )}
          </EditorInfoPanel>

          <EditorInfoPanel
            title="可选角色"
            bodyClassName="mt-3 grid gap-3 sm:grid-cols-2"
          >
            {availableNpcs.length > 0 ? (
              availableNpcs.map((npc) => {
                const isSelected = npc.id === draftNpcId;

                return (
                  <button
                    key={`${slotLabel}-${npc.id}`}
                    type="button"
                    onClick={() => setDraftNpcId(npc.id)}
                    className={`rounded-2xl border px-4 py-4 text-left transition-colors ${
                      isSelected
                        ? 'border-sky-300/35 bg-sky-500/10'
                        : 'border-white/8 bg-black/20 hover:border-white/20'
                    }`}
                  >
                    <div className="flex items-start gap-3">
                      <CustomWorldNpcPortrait
                        npc={npc}
                        visual={getSceneActSelectableNpcVisual(npc)}
                        className="h-16 w-16 shrink-0"
                        contentClassName="min-h-0 p-2"
                        scale={1.6}
                        preferImageSrc
                      />
                      <div className="min-w-0 flex-1">
                        <div className="truncate text-sm font-semibold text-white">
                          {npc.name}
                        </div>
                        <div className="mt-1 text-xs leading-6 text-zinc-400">
                          {npc.title || npc.role || '未填写身份'}
                        </div>
                      </div>
                      <PlatformPillBadge
                        tone={isSelected ? 'darkSky' : 'darkNeutral'}
                        size="xxs"
                        className={`px-2.5 ${isSelected ? 'text-sky-50' : 'text-zinc-400'}`}
                      >
                        {isSelected ? '已选中' : '选择'}
                      </PlatformPillBadge>
                    </div>
                  </button>
                );
              })
            ) : (
              <EditorEmptyState className="sm:col-span-2">
                当前世界档案里还没有可用于这一幕的场景角色。
              </EditorEmptyState>
            )}
          </EditorInfoPanel>
        </div>

        <div className="mt-4 grid shrink-0 grid-cols-4 gap-3 border-t border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] px-4 py-4">
          {selectedNpc ? (
            <ActionButton
              label="移除角色"
              onClick={() => {
                onApply(null);
                onClose();
              }}
              tone="rose"
              className="col-span-1 justify-center px-2"
            />
          ) : null}
          <ActionButton
            label="保存角色"
            onClick={() => {
              if (!draftNpcId) {
                onNotice('请先选择角色。');
                return;
              }
              onApply(draftNpcId);
              onClose();
            }}
            tone="sky"
            disabled={!draftNpcId}
            className={`justify-center ${
              selectedNpc ? 'col-span-3' : 'col-span-4'
            }`}
          />
        </div>
      </div>
    </ModalShell>
  );
}

function buildSceneActPreviewSceneNpc(npc: SceneActSelectableNpc): SceneNpc {
  return {
    id: npc.id,
    name: npc.name,
    title: npc.title,
    role: npc.role,
    avatar: (npc.imageSrc ?? npc.name.slice(0, 1)) || '?',
    description: npc.description || `${npc.name}会在当前幕与你正面相遇。`,
    initialAffinity: npc.initialAffinity,
    hostile: false,
    recruitable: (npc.initialAffinity ?? 0) >= 0,
    functions: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
    backstory: npc.backstory,
    personality: npc.personality,
    motivation: npc.motivation,
    combatStyle: npc.combatStyle,
    relationshipHooks: [...npc.relationshipHooks],
    tags: [...npc.tags],
    backstoryReveal: npc.backstoryReveal,
    skills: npc.skills.map((skill) => ({ ...skill })),
    initialItems: npc.initialItems.map((item) => ({
      ...item,
      tags: [...item.tags],
    })),
    imageSrc: npc.imageSrc,
    visual: getSceneActSelectableNpcVisual(npc),
    narrativeProfile: npc.narrativeProfile,
    attributeProfile: npc.attributeProfile,
  };
}

function buildSceneActPreviewScenePreset(params: {
  landmark: CustomWorldLandmark;
  act: SceneActBlueprint;
  encounterNpcs: SceneActSelectableNpc[];
}): ScenePresetInfo {
  return {
    id: params.landmark.id,
    name: params.landmark.name,
    description: params.landmark.description,
    imageSrc:
      params.act.backgroundImageSrc?.trim() ||
      params.landmark.imageSrc?.trim() ||
      '',
    connectedSceneIds: [],
    connections: [],
    treasureHints: [],
    narrativeResidues: params.landmark.narrativeResidues ?? [],
    npcs: params.encounterNpcs.map(buildSceneActPreviewSceneNpc),
  };
}

const SCENE_ACT_PREVIEW_SESSION_ID = 'runtime-scene-act-preview';

function buildSceneActPreviewNpcOption(params: {
  functionId: string;
  actionText: string;
  npcId: string;
  action: 'chat' | 'fight' | 'leave';
}): StoryOption {
  return {
    functionId: params.functionId,
    actionText: params.actionText,
    text: params.actionText,
    detailText: '',
    visuals: {
      playerAnimation: AnimationState.IDLE,
      playerMoveMeters: 0,
      playerOffsetY: 0,
      playerFacing: 'right',
      scrollWorld: false,
      monsterChanges: [],
    },
    interaction: {
      kind: 'npc',
      npcId: params.npcId,
      action: params.action,
    },
  };
}

function buildSceneActPreviewOpeningStory(params: {
  sceneName: string;
  encounter: NonNullable<ReturnType<typeof buildEncounterFromSceneNpc>>;
}): StoryMoment {
  const npcId = params.encounter.id ?? params.encounter.npcName;
  const openingText = `${params.encounter.npcName}已经在${params.sceneName || '当前场景'}等你。`;

  return {
    text: openingText,
    displayMode: 'dialogue',
    dialogue: [
      {
        speaker: 'npc',
        speakerName: params.encounter.npcName,
        text: openingText,
      },
    ],
    streaming: false,
    npcChatState: {
      npcId,
      npcName: params.encounter.npcName,
      turnCount: 0,
      customInputPlaceholder: '输入你想对 TA 说的话',
      openingSource: 'npc_initiated',
      sceneActId: null,
      turnLimit: null,
      remainingTurns: null,
      limitReason: null,
      forceExitAfterTurn: false,
      terminationMode: null,
      terminationReason: null,
      isHostileChat: false,
      pendingQuestOffer: null,
      combatContext: null,
    },
    options: [
      buildSceneActPreviewNpcOption({
        functionId: 'npc_chat',
        actionText: '先听他说完',
        npcId,
        action: 'chat',
      }),
      buildSceneActPreviewNpcOption({
        functionId: 'npc_fight',
        actionText: '与他对战',
        npcId,
        action: 'fight',
      }),
      buildSceneActPreviewNpcOption({
        functionId: 'npc_leave',
        actionText: '暂时离开',
        npcId,
        action: 'leave',
      }),
    ],
  };
}

function SceneActPreviewRuntime({
  profile,
  landmark,
  chapter,
  actIndex,
  onClose,
}: {
  profile: CustomWorldProfile;
  landmark: CustomWorldLandmark;
  chapter: SceneChapterBlueprint;
  actIndex: number;
  onClose: () => void;
}) {
  const authUi = useAuthUi();
  const act = chapter.acts[actIndex] ?? null;
  const encounterNpcs = useMemo(
    () =>
      (act?.encounterNpcIds ?? [])
        .map(
          (npcId) =>
            profile.playableNpcs.find((entry) => entry.id === npcId) ??
            profile.storyNpcs.find((entry) => entry.id === npcId) ??
            null,
        )
        .filter((npc): npc is SceneActSelectableNpc => Boolean(npc)),
    [act?.encounterNpcIds, profile.playableNpcs, profile.storyNpcs],
  );
  const previewScenePreset = useMemo(
    () =>
      act
        ? buildSceneActPreviewScenePreset({
            landmark,
            act,
            encounterNpcs,
          })
        : null,
    [act, encounterNpcs, landmark],
  );
  const previewEncounter = useMemo(() => {
    const primaryNpc = encounterNpcs[0];
    if (!primaryNpc) {
      return null;
    }

    return buildEncounterFromSceneNpc(
      buildSceneActPreviewSceneNpc(primaryNpc),
      RESOLVED_ENTITY_X_METERS,
    );
  }, [encounterNpcs]);
  const previewCharacter = useMemo(
    () =>
      buildCustomWorldPlayableCharacters(profile)[0] ??
      ROLE_TEMPLATE_CHARACTERS[0] ??
      null,
    [profile],
  );
  const previewActRuntimeState = useMemo(
    () =>
      act
        ? {
            sceneId: chapter.sceneId,
            chapterId: chapter.id,
            currentActId: act.id,
            currentActIndex: actIndex,
            completedActIds: chapter.acts
              .slice(0, actIndex)
              .map((entry) => entry.id),
            visitedActIds: chapter.acts
              .slice(0, actIndex + 1)
              .map((entry) => entry.id),
          }
        : null,
    [act, actIndex, chapter],
  );
  const hasBootstrappedRef = useRef(false);
  const {
    gameState,
    setGameState,
    bottomTab,
    setBottomTab,
    isMapOpen,
    setIsMapOpen,
    resetGame,
    handleCustomWorldSelect,
    handleCharacterSelect,
  } = useRpgSessionBootstrap();
  const combatFlow = useCombatFlow({
    setGameState,
  });
  const storyFlow = useRpgRuntimeStory({
    gameState,
    setGameState,
    buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
    playResolvedChoice: combatFlow.playResolvedChoice,
  });
  const { companionRenderStates, buildCompanionRenderStates } =
    useNpcInteractionFlow(gameState);
  const isPreviewReady =
    gameState.currentScene === 'Story' &&
    gameState.runtimeSessionId === SCENE_ACT_PREVIEW_SESSION_ID &&
    gameState.playerCharacter?.id === previewCharacter?.id &&
    gameState.currentScenePreset?.id === previewScenePreset?.id &&
    Boolean(storyFlow.currentStory);

  useEffect(() => {
    if (
      hasBootstrappedRef.current ||
      !act ||
      !previewCharacter ||
      !previewScenePreset ||
      !previewEncounter ||
      !previewActRuntimeState
    ) {
      return;
    }

    hasBootstrappedRef.current = true;
    storyFlow.resetStoryState();
    setBottomTab('adventure');
    setIsMapOpen(false);
    // 中文注释：幕预览只需要同步静态资料层，不能调用正式选世界入口；
    // 后者会排队写入“已选世界但未选角”的中间态，把本地预览 GameState 覆盖回加载中。
    setRuntimeCustomWorldProfile(profile);
    setRuntimeCharacterOverrides(buildCustomWorldRuntimeCharacters(profile));
    const previewCharacterMaxHp = getCharacterMaxHp(
      previewCharacter,
      WorldType.CUSTOM,
      profile,
    );
    const previewCharacterMaxMana = getCharacterMaxMana(previewCharacter);
    const previewEquipment = buildInitialEquipmentLoadout(
      previewCharacter,
      profile,
    );
    setGameState((current) => ({
      ...current,
      worldType: WorldType.CUSTOM,
      customWorldProfile: profile,
      playerCharacter: previewCharacter,
      runtimeSessionId: SCENE_ACT_PREVIEW_SESSION_ID,
      runtimeActionVersion: 1,
      // 中文注释：幕预览也统一复用正式 play 运行链，
      // 只通过禁持久化控制“不写正式存档”。
      runtimeMode: 'play',
      runtimePersistenceDisabled: true,
      runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
      playerProgression: createInitialPlayerProgressionState(),
      currentScene: 'Story',
      currentScenePreset: previewScenePreset,
      currentEncounter: previewEncounter,
      npcInteractionActive: false,
      sceneHostileNpcs: [],
      inBattle: false,
      playerHp: previewCharacterMaxHp,
      playerMaxHp: previewCharacterMaxHp,
      playerMana: previewCharacterMaxMana,
      playerMaxMana: previewCharacterMaxMana,
      playerSkillCooldowns: createCharacterSkillCooldowns(previewCharacter),
      playerCurrency: 0,
      playerInventory: [],
      playerEquipment: previewEquipment,
      npcStates: {},
      quests: [],
      roster: [],
      companions: [],
      storyHistory: [],
      chapterState: null,
      campaignState: null,
      activeScenarioPackId: profile.scenarioPackId ?? null,
      activeCampaignPackId: profile.campaignPackId ?? null,
      currentBattleNpcId: null,
      currentNpcBattleMode: null,
      currentNpcBattleOutcome: null,
      sparReturnEncounter: null,
      sparPlayerHpBefore: null,
      sparPlayerMaxHpBefore: null,
      sparStoryHistoryBefore: null,
      characterChats: {},
      playerX: 0,
      playerOffsetY: 0,
      playerFacing: 'right',
      playerActionMode: 'idle',
      scrollWorld: false,
      animationState: AnimationState.IDLE,
      activeCombatEffects: [],
      activeBuildBuffs: [],
      lastObserveSignsSceneId: null,
      lastObserveSignsReport: null,
      storyEngineMemory: {
        ...(current.storyEngineMemory ?? createEmptyStoryEngineMemoryState()),
        currentChapter: null,
        currentSceneActState: previewActRuntimeState,
      },
    }));
    storyFlow.hydrateStoryState(
      buildSceneActPreviewOpeningStory({
        sceneName: previewScenePreset.name,
        encounter: previewEncounter,
      }),
    );
  }, [
    act,
    previewActRuntimeState,
    previewCharacter,
    previewEncounter,
    previewScenePreset,
    profile,
    setBottomTab,
    setGameState,
    setIsMapOpen,
    storyFlow,
  ]);

  if (!act || !previewCharacter || !previewScenePreset || !previewEncounter) {
    return (
      <div className="flex h-full items-center justify-center px-6 text-center text-sm text-zinc-300">
        当前幕还缺少可预览的主角色。
      </div>
    );
  }

  if (!isPreviewReady) {
    return (
      <div className="flex h-full items-center justify-center px-6 text-center text-sm text-zinc-300">
        正在载入这一幕的游戏流程...
      </div>
    );
  }

  return (
    <RpgRuntimeShell
      session={{
        gameState,
        currentStory: storyFlow.currentStory,
        isLoading: storyFlow.isLoading,
        aiError: storyFlow.aiError,
        bottomTab,
        setBottomTab,
        isMapOpen,
        setIsMapOpen,
      }}
      story={{
        displayedOptions: storyFlow.displayedOptions,
        canRefreshOptions: storyFlow.canRefreshOptions,
        handleRefreshOptions: storyFlow.handleRefreshOptions,
        handleChoice: storyFlow.handleChoice,
        handleNpcChatInput: storyFlow.handleNpcChatInput,
        refreshNpcChatOptions: storyFlow.refreshNpcChatOptions,
        exitNpcChat: storyFlow.exitNpcChat,
        handleMapTravelToScene: storyFlow.travelToSceneFromMap,
        npcUi: storyFlow.npcUi,
        characterChatUi: storyFlow.characterChatUi,
        inventoryUi: storyFlow.inventoryUi,
        battleRewardUi: storyFlow.battleRewardUi,
        questUi: storyFlow.questUi,
        npcChatQuestOfferUi: storyFlow.npcChatQuestOfferUi,
        goalUi: storyFlow.goalUi,
      }}
      entry={{
        hasSavedGame: false,
        savedSnapshot: null,
        handleContinueGame: () => undefined,
        handleStartNewGame: () => {
          resetGame();
          onClose();
        },
        handleSaveAndExit: onClose,
        handleCustomWorldSelect,
        handleBackToWorldSelect: onClose,
        handleCharacterSelect,
      }}
      companions={{
        companionRenderStates,
        buildCompanionRenderStates,
        onBenchCompanion: () => undefined,
        onActivateRosterCompanion: () => undefined,
      }}
      audio={{
        musicVolume: authUi?.musicVolume ?? 0.6,
        onMusicVolumeChange: authUi?.setMusicVolume ?? (() => {}),
      }}
      chrome={{
        hidePlayerLevelBadge: true,
      }}
    />
  );
}

function SceneActPreviewModal({
  profile,
  landmark,
  chapter,
  actIndex,
  onClose,
}: {
  profile: CustomWorldProfile;
  landmark: CustomWorldLandmark;
  chapter: SceneChapterBlueprint;
  actIndex: number;
  onClose: () => void;
}) {
  const act = chapter.acts[actIndex] ?? null;

  if (!act) {
    return null;
  }

  return createPortal(
    <div className="fixed inset-0 z-[160] bg-black">
      <div className="pointer-events-none absolute inset-x-0 top-0 z-[2] bg-[linear-gradient(180deg,rgba(8,10,17,0.84)_0%,rgba(8,10,17,0)_100%)] px-4 py-4 sm:px-6">
        <div>
          <div className="text-[11px] font-bold tracking-[0.18em] text-zinc-400">
            幕预览
          </div>
          <div className="mt-1 text-sm font-semibold text-white">
            {act.title.trim() || buildDefaultSceneActTitle(actIndex)}
          </div>
        </div>
      </div>
      <SceneActPreviewRuntime
        profile={profile}
        landmark={landmark}
        chapter={chapter}
        actIndex={actIndex}
        onClose={onClose}
      />
      <div
        className="fixed inset-x-0 z-[170] flex justify-center px-4"
        style={{
          top: 'calc(36vh - 3.25rem)',
        }}
      >
        <ActionButton label="结束预览" onClick={onClose} />
      </div>
    </div>,
    document.body,
  );
}

function ConnectionDirectionSlot({
  direction,
  targetName,
  compact = false,
  onClick,
}: {
  direction: CardinalConnectionDirection;
  targetName?: string;
  compact?: boolean;
  onClick?: (() => void) | null;
}) {
  const content = (
    <div
      className={`rounded-2xl border px-3 py-3 text-center transition-colors ${
        targetName
          ? 'border-sky-300/26 bg-sky-500/10 text-sky-50'
          : 'border-dashed border-white/12 bg-black/20 text-zinc-500'
      } ${compact ? 'min-h-[4.5rem]' : 'min-h-[5.5rem]'} ${
        onClick ? 'hover:border-white/25 hover:text-white' : ''
      }`}
    >
      <div className="text-[10px] font-bold tracking-[0.18em] text-zinc-400">
        {CARDINAL_CONNECTION_LABELS[direction]}
      </div>
      <div
        className={`mt-2 font-semibold leading-5 ${
          compact ? 'text-xs' : 'text-sm'
        }`}
      >
        {targetName || '空'}
      </div>
    </div>
  );

  if (!onClick) {
    return content;
  }

  return (
    <button type="button" onClick={onClick} className="w-full text-left">
      {content}
    </button>
  );
}

function DirectionalSceneConnectionCompass({
  centerName,
  directionTargets,
  compact = false,
  onDirectionClick,
}: {
  centerName: string;
  directionTargets: Partial<Record<CardinalConnectionDirection, string>>;
  compact?: boolean;
  onDirectionClick?: ((direction: CardinalConnectionDirection) => void) | null;
}) {
  return (
    <div className="grid grid-cols-3 gap-3">
      <div />
      <ConnectionDirectionSlot
        direction="north"
        targetName={directionTargets.north}
        compact={compact}
        onClick={onDirectionClick ? () => onDirectionClick('north') : undefined}
      />
      <div />
      <ConnectionDirectionSlot
        direction="west"
        targetName={directionTargets.west}
        compact={compact}
        onClick={onDirectionClick ? () => onDirectionClick('west') : undefined}
      />
      <div
        className={`rounded-[1.6rem] border border-amber-300/22 bg-amber-500/10 px-4 py-4 text-center ${
          compact ? 'min-h-[4.5rem]' : 'min-h-[5.5rem]'
        }`}
      >
        <div className="text-[10px] font-bold tracking-[0.18em] text-amber-100/75">
          当前场景
        </div>
        <div
          className={`mt-2 font-semibold leading-5 text-white ${
            compact ? 'text-xs' : 'text-sm'
          }`}
        >
          {centerName}
        </div>
      </div>
      <ConnectionDirectionSlot
        direction="east"
        targetName={directionTargets.east}
        compact={compact}
        onClick={onDirectionClick ? () => onDirectionClick('east') : undefined}
      />
      <div />
      <ConnectionDirectionSlot
        direction="south"
        targetName={directionTargets.south}
        compact={compact}
        onClick={onDirectionClick ? () => onDirectionClick('south') : undefined}
      />
      <div />
    </div>
  );
}

function SceneConnectionTargetPickerModal({
  direction,
  landmarks,
  currentTargetId,
  onSelect,
  onRemove,
  onClose,
}: {
  direction: CardinalConnectionDirection;
  landmarks: CustomWorldLandmark[];
  currentTargetId?: string;
  onSelect: (landmarkId: string) => void;
  onRemove: () => void;
  onClose: () => void;
}) {
  return (
    <ModalShell
      title={`${CARDINAL_CONNECTION_LABELS[direction]}侧连接`}
      onClose={onClose}
      panelClassName="sm:max-w-xl"
    >
      <div className="space-y-3">
        {landmarks.map((landmark) => {
          const isSelected = landmark.id === currentTargetId;
          return (
            <button
              key={landmark.id}
              type="button"
              onClick={() => {
                onSelect(landmark.id);
                onClose();
              }}
              className={`w-full rounded-2xl border px-4 py-4 text-left transition-colors ${
                isSelected
                  ? 'border-sky-300/35 bg-sky-500/10'
                  : 'border-white/8 bg-black/20 hover:border-white/20'
              }`}
            >
              <div className="flex items-start justify-between gap-3">
                <div className="min-w-0">
                  <div className="text-sm font-semibold text-white">
                    {landmark.name}
                  </div>
                  {landmark.description ? (
                    <div className="mt-1 text-xs leading-6 text-zinc-400">
                      {landmark.description}
                    </div>
                  ) : null}
                </div>
                {isSelected ? (
                  <PlatformPillBadge
                    tone="darkSky"
                    size="xxs"
                    className="px-2.5 text-sky-50"
                  >
                    当前连接
                  </PlatformPillBadge>
                ) : null}
              </div>
            </button>
          );
        })}
        <div className="flex flex-col-reverse gap-3 pt-1 sm:flex-row sm:justify-end">
          <ActionButton label="取消" onClick={onClose} />
          {currentTargetId ? (
            <ActionButton
              label="清空连接"
              onClick={() => {
                onRemove();
                onClose();
              }}
            />
          ) : null}
        </div>
      </div>
    </ModalShell>
  );
}

type WorldMapNodeLayout = {
  id: string;
  name: string;
  description: string;
  isCurrent: boolean;
  left: number;
  top: number;
  centerX: number;
  centerY: number;
};

type WorldMapEdgeLayout = {
  fromId: string;
  toId: string;
  label: string;
  labelX: number;
  labelY: number;
};

const WORLD_MAP_NODE_WIDTH = 152;
const WORLD_MAP_NODE_HEIGHT = 88;
const WORLD_MAP_GRID_WIDTH = 196;
const WORLD_MAP_GRID_HEIGHT = 132;
const WORLD_MAP_PADDING = 48;

function getWorldMapDirectionOffset(direction: CardinalConnectionDirection) {
  switch (direction) {
    case 'north':
      return { x: 0, y: -1 };
    case 'east':
      return { x: 1, y: 0 };
    case 'south':
      return { x: 0, y: 1 };
    case 'west':
      return { x: -1, y: 0 };
  }
}

function buildWorldMapLayout(
  landmarks: CustomWorldLandmark[],
  currentSceneId?: string | null,
) {
  const directionalConnectionMap = new Map(
    landmarks.map((landmark) => [
      landmark.id,
      buildDirectionalConnections(landmark.connections, landmarks),
    ]),
  );
  const landmarkById = new Map(
    landmarks.map((landmark) => [landmark.id, landmark]),
  );
  const positions = new Map<string, { x: number; y: number }>();
  const occupied = new Map<string, string>();
  const queue: string[] = [];
  let clusterAnchorX = 0;

  const reservePosition = (landmarkId: string, x: number, y: number) => {
    const key = `${x},${y}`;
    const occupiedBy = occupied.get(key);
    if (!occupiedBy || occupiedBy === landmarkId) {
      occupied.set(key, landmarkId);
      positions.set(landmarkId, { x, y });
      return { x, y };
    }

    for (let step = 1; step <= 6; step += 1) {
      const candidates = [
        { x, y: y + step },
        { x, y: y - step },
        { x: x + step, y },
        { x: x - step, y },
      ];
      const available = candidates.find(
        (candidate) => !occupied.has(`${candidate.x},${candidate.y}`),
      );
      if (available) {
        occupied.set(`${available.x},${available.y}`, landmarkId);
        positions.set(landmarkId, available);
        return available;
      }
    }

    occupied.set(key, landmarkId);
    positions.set(landmarkId, { x, y });
    return { x, y };
  };

  landmarks.forEach((landmark) => {
    if (positions.has(landmark.id)) {
      return;
    }

    reservePosition(landmark.id, clusterAnchorX, 0);
    queue.push(landmark.id);
    let clusterMaxX = clusterAnchorX;

    while (queue.length > 0) {
      const currentId = queue.shift();
      if (!currentId) {
        continue;
      }

      const currentPosition = positions.get(currentId);
      if (!currentPosition) {
        continue;
      }

      const connections = directionalConnectionMap.get(currentId) ?? [];
      connections.forEach((connection) => {
        const target = landmarkById.get(connection.targetLandmarkId);
        if (!target || positions.has(target.id)) {
          return;
        }

        const offset = getWorldMapDirectionOffset(
          connection.relativePosition as CardinalConnectionDirection,
        );
        const nextPosition = reservePosition(
          target.id,
          currentPosition.x + offset.x,
          currentPosition.y + offset.y,
        );
        clusterMaxX = Math.max(clusterMaxX, nextPosition.x);
        queue.push(target.id);
      });
    }

    clusterAnchorX = clusterMaxX + 3;
  });

  const coordinateEntries = landmarks.map((landmark) => {
    const position =
      positions.get(landmark.id) ?? reservePosition(landmark.id, 0, 0);
    return { landmark, x: position.x, y: position.y };
  });

  const minX = Math.min(...coordinateEntries.map((entry) => entry.x), 0);
  const maxX = Math.max(...coordinateEntries.map((entry) => entry.x), 0);
  const minY = Math.min(...coordinateEntries.map((entry) => entry.y), 0);
  const maxY = Math.max(...coordinateEntries.map((entry) => entry.y), 0);

  const nodes: WorldMapNodeLayout[] = coordinateEntries.map(
    ({ landmark, x, y }) => {
      const left = WORLD_MAP_PADDING + (x - minX) * WORLD_MAP_GRID_WIDTH;
      const top = WORLD_MAP_PADDING + (y - minY) * WORLD_MAP_GRID_HEIGHT;

      return {
        id: landmark.id,
        name: landmark.name,
        description: landmark.description,
        isCurrent: landmark.id === currentSceneId,
        left,
        top,
        centerX: left + WORLD_MAP_NODE_WIDTH / 2,
        centerY: top + WORLD_MAP_NODE_HEIGHT / 2,
      };
    },
  );

  const edgeMap = new Map<string, WorldMapEdgeLayout>();
  directionalConnectionMap.forEach((connections, sourceId) => {
    connections.forEach((connection) => {
      if (!landmarkById.has(connection.targetLandmarkId)) {
        return;
      }

      const pairKey = [sourceId, connection.targetLandmarkId].sort().join('::');
      if (!edgeMap.has(pairKey)) {
        const fromNode = nodes.find((node) => node.id === sourceId);
        const toNode = nodes.find(
          (node) => node.id === connection.targetLandmarkId,
        );

        edgeMap.set(pairKey, {
          fromId: sourceId,
          toId: connection.targetLandmarkId,
          label:
            CARDINAL_CONNECTION_LABELS[
              connection.relativePosition as CardinalConnectionDirection
            ],
          labelX:
            fromNode && toNode ? (fromNode.centerX + toNode.centerX) / 2 : 0,
          labelY:
            fromNode && toNode ? (fromNode.centerY + toNode.centerY) / 2 : 0,
        });
      }
    });
  });

  return {
    nodes,
    edges: [...edgeMap.values()],
    width:
      WORLD_MAP_PADDING * 2 +
      (maxX - minX) * WORLD_MAP_GRID_WIDTH +
      WORLD_MAP_NODE_WIDTH,
    height:
      WORLD_MAP_PADDING * 2 +
      (maxY - minY) * WORLD_MAP_GRID_HEIGHT +
      WORLD_MAP_NODE_HEIGHT,
  };
}

function WorldMapOverviewModal({
  landmarks,
  currentSceneId,
  onClose,
}: {
  landmarks: CustomWorldLandmark[];
  currentSceneId?: string | null;
  onClose: () => void;
}) {
  const { nodes, edges, width, height } = useMemo(
    () => buildWorldMapLayout(landmarks, currentSceneId),
    [currentSceneId, landmarks],
  );
  const nodeById = useMemo(
    () => new Map(nodes.map((node) => [node.id, node])),
    [nodes],
  );

  return (
    <ModalShell
      title="世界地图"
      onClose={onClose}
      panelClassName="sm:max-w-6xl"
    >
      <div className="max-h-[72vh] overflow-auto rounded-[1.6rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-page-fill)] p-3 sm:p-4">
        <div
          className="relative"
          style={{
            width: `${width}px`,
            height: `${height}px`,
            minWidth: '100%',
          }}
        >
          {nodes.length === 0 ? (
            <div className="absolute inset-0 flex items-center justify-center text-sm text-[var(--platform-text-soft)]">
              暂无场景
            </div>
          ) : null}
          <svg
            className="absolute inset-0"
            width={width}
            height={height}
            viewBox={`0 0 ${width} ${height}`}
          >
            {edges.map((edge) => {
              const fromNode = nodeById.get(edge.fromId);
              const toNode = nodeById.get(edge.toId);
              if (!fromNode || !toNode) {
                return null;
              }

              return (
                <line
                  key={`${edge.fromId}-${edge.toId}`}
                  x1={fromNode.centerX}
                  y1={fromNode.centerY}
                  x2={toNode.centerX}
                  y2={toNode.centerY}
                  stroke="var(--platform-line-soft)"
                  strokeWidth="2"
                  strokeLinecap="round"
                />
              );
            })}
            {edges.map((edge) => (
              <g key={`${edge.fromId}-${edge.toId}-label`}>
                <circle
                  cx={edge.labelX}
                  cy={edge.labelY}
                  r="12"
                  fill="var(--platform-input-fill)"
                  stroke="var(--platform-subpanel-border)"
                  strokeWidth="1"
                />
                <text
                  x={edge.labelX}
                  y={edge.labelY + 4}
                  textAnchor="middle"
                  className="fill-[var(--platform-text-soft)] text-[10px] font-bold"
                >
                  {edge.label}
                </text>
              </g>
            ))}
          </svg>

          {nodes.map((node) => (
            <div
              key={node.id}
              className={`platform-subpanel absolute rounded-[1.25rem] px-4 py-3 shadow-[0_14px_34px_rgba(20,12,24,0.12)] ${
                node.isCurrent
                  ? 'border-[var(--platform-button-primary-border)] ring-2 ring-[var(--platform-input-focus-ring)]'
                  : ''
              }`}
              style={{
                left: `${node.left}px`,
                top: `${node.top}px`,
                width: `${WORLD_MAP_NODE_WIDTH}px`,
                minHeight: `${WORLD_MAP_NODE_HEIGHT}px`,
              }}
            >
              <div className="flex items-start justify-between gap-2">
                <div className="min-w-0 text-sm font-semibold text-[var(--platform-text-strong)]">
                  {node.name}
                </div>
                {node.isCurrent ? (
                  <PlatformPillBadge
                    tone="muted"
                    size="xxs"
                    className="shrink-0 px-2 py-0.5 font-semibold"
                  >
                    当前
                  </PlatformPillBadge>
                ) : null}
              </div>
              {node.description ? (
                <div className="mt-2 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
                  {node.description}
                </div>
              ) : null}
            </div>
          ))}
        </div>
      </div>
    </ModalShell>
  );
}

const FIXED_SCENE_IMAGE_SIZE = '1280*720';

function SceneImageGenerationModal({
  profile,
  landmark,
  initialPromptText,
  initialPreviewImageSrc,
  onApply,
  onClose,
}: {
  profile: CustomWorldProfile;
  landmark: CustomWorldLandmark;
  initialPromptText?: string;
  initialPreviewImageSrc?: string | null;
  onApply: (result: CustomWorldSceneImageResult) => void;
  onClose: () => void;
}) {
  const [userPrompt, setUserPrompt] = useDraft(
    initialPromptText?.trim() ||
      landmark.visualDescription?.trim() ||
      landmark.description.trim() ||
      landmark.name.trim(),
  );
  const [referenceImageSrc, setReferenceImageSrc] = useState('');
  const [isGenerating, setIsGenerating] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [latestResult, setLatestResult] =
    useState<CustomWorldSceneImageResult | null>(null);
  const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
  const referenceImageInputId = useId();

  const originalImageSrc = useMemo(() => {
    const initialPreview = initialPreviewImageSrc?.trim() || '';
    if (initialPreview) {
      return initialPreview;
    }
    const landmarkIndex = profile.landmarks.findIndex(
      (entry) => entry.id === landmark.id,
    );

    return resolveCustomWorldLandmarkImage(
      profile,
      landmark,
      landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
      profile.landmarks
        .filter((entry) => entry.id !== landmark.id)
        .map((entry) => entry.imageSrc)
        .filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
    );
  }, [initialPreviewImageSrc, landmark, profile]);

  const previewImageSrc = latestResult?.imageSrc || originalImageSrc;

  const handleReferenceImageChange = async (
    event: ChangeEvent<HTMLInputElement>,
  ) => {
    const file = event.target.files?.[0];
    event.currentTarget.value = '';
    if (!file) {
      return;
    }

    try {
      const dataUrl = await readImageFileAsDataUrl(file);
      setReferenceImageSrc(dataUrl);
      setError(null);
    } catch (uploadError) {
      setError(
        uploadError instanceof Error
          ? uploadError.message
          : '参考图读取失败，请重试。',
      );
    }
  };

  const handleRequestClose = () => {
    if (isGenerating) {
      return;
    }
    if (latestResult) {
      setIsExitConfirmOpen(true);
      return;
    }
    onClose();
  };

  const handleGenerate = async () => {
    if (!userPrompt.trim()) {
      setError('请先描述想要生成的画面内容。');
      return;
    }

    setIsGenerating(true);
    setError(null);

    try {
      const result = await rpgCreationAssetClient.generateSceneImage({
        profile,
        landmark,
        userPrompt,
        size: FIXED_SCENE_IMAGE_SIZE,
        ...(referenceImageSrc ? { referenceImageSrc } : {}),
      });
      setLatestResult(result);
    } catch (generationError) {
      setError(
        generationError instanceof Error
          ? generationError.message
          : '场景图片生成失败，请稍后重试。',
      );
    } finally {
      setIsGenerating(false);
    }
  };

  const handleSave = () => {
    if (!latestResult || isGenerating) {
      return;
    }
    onApply(latestResult);
    onClose();
  };

  return (
    <>
      <ModalShell
        title={`智能生成：${landmark.name || '当前场景'}`}
        onClose={handleRequestClose}
        panelClassName="sm:max-w-4xl"
        overlayClassName="z-[99]"
        disableClose={isGenerating}
        usePixelFont
      >
        <div className="grid gap-5 lg:grid-cols-[minmax(0,1.15fr)_minmax(17rem,0.85fr)]">
          <div className="space-y-4">
            <Field label="画面内容描述">
              <TextArea
                value={userPrompt}
                onChange={(value) => setUserPrompt(value)}
                rows={8}
                placeholder="例如：雨夜的悬桥横跨黑色峡谷，桥下翻涌蓝绿色雾潮，远处有半坍塌塔楼与零星灯火。"
              />
            </Field>

            <Field label="自定义参考图（可选）">
              <div className="space-y-3">
                <PlatformUploadTile
                  asChild="label"
                  htmlFor={referenceImageInputId}
                  label="上传自定义参考图"
                  hint="png / jpg / webp"
                  size="compact"
                  surface="editorDark"
                  disabled={isGenerating}
                />
                <input
                  id={referenceImageInputId}
                  type="file"
                  accept="image/png,image/jpeg,image/webp"
                  disabled={isGenerating}
                  onChange={(event) => {
                    void handleReferenceImageChange(event);
                  }}
                  className="sr-only"
                />
                {referenceImageSrc ? (
                  <PlatformUploadPreviewCard
                    layout="inline"
                    surface="editorDark"
                    imageSrc={referenceImageSrc}
                    imageAlt="自定义参考图"
                    caption="已载入自定义参考图"
                    removeLabel="移除自定义参考图"
                    onRemove={() => setReferenceImageSrc('')}
                    disabled={isGenerating}
                    imageShellClassName="!h-16 !w-24 rounded-xl"
                  />
                ) : null}
              </div>
            </Field>
          </div>

          <div className="space-y-4">
            <div className="rounded-2xl border border-white/8 bg-black/18 p-3">
              <ImagePreview
                src={previewImageSrc}
                alt={landmark.name || '场景预览'}
                fallbackLabel={
                  landmark.name ? landmark.name.slice(0, 4) : '场景'
                }
                tone="landscape"
              />
            </div>

            {latestResult ? (
              <PlatformStatusMessage
                tone="success"
                surface="tinted"
                size="md"
                className={RPG_EDITOR_STATUS_SUCCESS_CLASS_NAME}
              >
                已生成完毕，请保存后再退出页面
              </PlatformStatusMessage>
            ) : null}

            {error ? (
              <PlatformStatusMessage
                tone="error"
                surface="tinted"
                size="md"
                className={RPG_EDITOR_STATUS_ERROR_CLASS_NAME}
              >
                {error}
              </PlatformStatusMessage>
            ) : null}

            <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
              <ActionButton
                label="保存"
                onClick={handleSave}
                disabled={!latestResult || isGenerating}
              />
              <ActionButton
                label={
                  isGenerating
                    ? '正在生成...'
                    : latestResult
                      ? '重新生成'
                      : '开始生成'
                }
                onClick={() => {
                  void handleGenerate();
                }}
                tone="sky"
                disabled={isGenerating}
              />
            </div>
          </div>
        </div>
      </ModalShell>

      {isExitConfirmOpen ? (
        <UnifiedConfirmDialog
          open
          title="确认退出"
          onClose={() => setIsExitConfirmOpen(false)}
          confirmLabel="仍然退出"
          cancelLabel="继续编辑"
          showCancel
          onConfirm={() => {
            setIsExitConfirmOpen(false);
            onClose();
          }}
          variant="pixel"
          overlayClassName="z-[140]"
        >
          当前生成画面还未保存，退出后将丢失这次生成结果，仍然退出吗？
        </UnifiedConfirmDialog>
      ) : null}
    </>
  );
}

function SceneActBackgroundModal({
  profile,
  landmark,
  act,
  actLabel,
  currentImageSrc,
  fallbackImageSrc,
  onApply,
  onClose,
}: {
  profile: CustomWorldProfile;
  landmark: CustomWorldLandmark;
  act: SceneActBlueprint;
  actLabel: string;
  currentImageSrc?: string | null;
  fallbackImageSrc?: string | null;
  onApply: (imageSrc?: string | null, assetId?: string | null) => void;
  onClose: () => void;
}) {
  const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
  const [draftImageSrc, setDraftImageSrc] = useDraft(
    currentImageSrc?.trim() || '',
  );
  const [draftAssetId, setDraftAssetId] = useDraft(
    act.backgroundAssetId?.trim() || '',
  );
  const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
  const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
  const previewImageSrc = draftImageSrc || fallbackImageSrc || '';

  return (
    <>
      <ModalShell
        title={`配置幕背景：${actLabel}`}
        onClose={onClose}
        panelClassName="sm:max-w-5xl"
      >
        <div className="space-y-4">
          <PlatformSubpanel surface="dark" radius="md" padding="md">
            <ImagePreview
              src={previewImageSrc || undefined}
              alt={`${actLabel}背景预览`}
              fallbackLabel="暂无背景图"
              tone="landscape"
            />
            <div className="mt-3 flex flex-wrap gap-3">
              <ActionButton
                label="跟随场景主图"
                onClick={() => {
                  setDraftImageSrc('');
                  setDraftAssetId('');
                }}
                tone="sky"
              />
              <ActionButton
                label="AI生成"
                onClick={() => setIsAiGenerateOpen(true)}
              />
              <ActionButton
                label="使用历史素材"
                onClick={() => setIsHistoryPickerOpen(true)}
              />
            </div>
          </PlatformSubpanel>

          <EditorInfoPanel
            title="预设背景"
            bodyClassName="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3"
          >
            {presetImages.map((src, index) => {
              const isSelected = src === draftImageSrc;

              return (
                <button
                  key={`${actLabel}-preset-${index}-${src || 'empty'}`}
                  type="button"
                  onClick={() => setDraftImageSrc(src)}
                  className={`overflow-hidden rounded-2xl border text-left transition-colors ${
                    isSelected
                      ? 'border-sky-300/55 bg-sky-500/10'
                      : 'border-white/10 bg-black/20 hover:border-white/25'
                  }`}
                >
                  <PlatformMediaFrame
                    src={src}
                    alt={`幕背景预设 ${index + 1}`}
                    fallbackLabel={`预设 ${index + 1}`}
                    aspect="landscape"
                    surface="none"
                    loading="lazy"
                    className="rounded-none"
                    imageClassName="h-full w-full object-cover"
                    previewOverlay={
                      <div className="absolute inset-x-0 bottom-0 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.82)_100%)] px-3 py-2 text-[11px] text-zinc-100">
                        预设 #{(index + 1).toString().padStart(3, '0')}
                      </div>
                    }
                  />
                </button>
              );
            })}
          </EditorInfoPanel>

          <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
            <ActionButton label="取消" onClick={onClose} />
            <ActionButton
              label="保存背景"
              onClick={() => {
                onApply(draftImageSrc || undefined, draftAssetId);
                onClose();
              }}
              tone="sky"
            />
          </div>
        </div>
      </ModalShell>

      {isAiGenerateOpen ? (
        <SceneImageGenerationModal
          profile={profile}
          landmark={landmark}
          initialPromptText={
            act.backgroundPromptText?.trim() ||
            landmark.visualDescription?.trim() ||
            landmark.description.trim() ||
            landmark.name.trim()
          }
          initialPreviewImageSrc={previewImageSrc}
          onApply={(result) => {
            setDraftImageSrc(result.imageSrc);
            setDraftAssetId(result.assetId);
          }}
          onClose={() => setIsAiGenerateOpen(false)}
        />
      ) : null}
      {isHistoryPickerOpen ? (
        <HistoryAssetPickerModal
          title="使用历史素材"
          kind="scene_image"
          tone="landscape"
          onSelect={(asset) => {
            setDraftImageSrc(asset.imageSrc);
            setDraftAssetId(asset.assetObjectId);
            setIsHistoryPickerOpen(false);
          }}
          onClose={() => setIsHistoryPickerOpen(false)}
        />
      ) : null}
    </>
  );
}

const FIXED_COVER_IMAGE_SIZE = '1600*900';
const COVER_IMAGE_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;

function buildGeneratedCoverProfile(
  result: CustomWorldCoverAssetResult,
): CustomWorldCoverProfile {
  return {
    sourceType: result.sourceType,
    imageSrc: result.imageSrc,
    characterRoleIds: [],
  };
}

function CoverUploadCropModal({
  imageDataUrl,
  imageSize,
  isSubmitting,
  onCancel,
  onConfirm,
}: {
  imageDataUrl: string;
  imageSize: { width: number; height: number };
  isSubmitting: boolean;
  onCancel: () => void;
  onConfirm: (cropRect: CustomWorldCoverCropRect) => void;
}) {
  const previewRef = useRef<HTMLDivElement | null>(null);
  const dragSnapshotRef = useRef<CoverCropDragSnapshot | null>(null);
  const [activeDragHandle, setActiveDragHandle] =
    useState<CoverCropDragHandle | null>(null);
  const [cropRect, setCropRect] = useState(() =>
    normalizeCoverCropRect(
      buildCenteredCoverCropRect(imageSize.width, imageSize.height),
      imageSize,
    ),
  );

  useEffect(() => {
    setActiveDragHandle(null);
    dragSnapshotRef.current = null;
    setCropRect(
      normalizeCoverCropRect(
        buildCenteredCoverCropRect(imageSize.width, imageSize.height),
        imageSize,
      ),
    );
  }, [imageDataUrl, imageSize]);

  const previewStyle = useMemo(
    () => buildCoverCropPreviewStyle(cropRect, imageSize),
    [cropRect, imageSize],
  );
  const editorPreviewStyle = useMemo(
    () =>
      ({
        aspectRatio: `${imageSize.width} / ${imageSize.height}`,
        width: `min(100%, calc(min(58vh, 34rem) * ${
          imageSize.width / Math.max(1, imageSize.height)
        }))`,
      }) satisfies CSSProperties,
    [imageSize],
  );
  const outputPreviewStyle = useMemo(
    () =>
      ({
        left: `${-(cropRect.x / cropRect.width) * 100}%`,
        top: `${-(cropRect.y / cropRect.height) * 100}%`,
        width: `${(imageSize.width / cropRect.width) * 100}%`,
        height: `${(imageSize.height / cropRect.height) * 100}%`,
      }) satisfies CSSProperties,
    [cropRect, imageSize],
  );

  const beginCropDrag = (
    handle: CoverCropDragHandle,
    event: PointerEvent<HTMLElement>,
  ) => {
    if (isSubmitting) {
      return;
    }

    const preview = previewRef.current;
    if (!preview) {
      return;
    }

    const rect = preview.getBoundingClientRect();
    dragSnapshotRef.current = {
      pointerId: event.pointerId,
      handle,
      clientX: event.clientX,
      clientY: event.clientY,
      cropRect,
      previewWidth: rect.width,
      previewHeight: rect.height,
    };
    setActiveDragHandle(handle);
    event.preventDefault();
    event.stopPropagation();
    event.currentTarget.setPointerCapture(event.pointerId);
  };

  const updateCropDrag = (event: PointerEvent<HTMLElement>) => {
    const snapshot = dragSnapshotRef.current;
    if (!snapshot || snapshot.pointerId !== event.pointerId) {
      return;
    }

    const deltaX =
      ((event.clientX - snapshot.clientX) * imageSize.width) /
      Math.max(1, snapshot.previewWidth);
    const deltaY =
      ((event.clientY - snapshot.clientY) * imageSize.height) /
      Math.max(1, snapshot.previewHeight);
    setCropRect(
      resizeCoverCropRectFromHandle(snapshot, deltaX, deltaY, imageSize),
    );
  };

  const stopCropDrag = (event: PointerEvent<HTMLElement>) => {
    if (dragSnapshotRef.current?.pointerId !== event.pointerId) {
      return;
    }

    dragSnapshotRef.current = null;
    setActiveDragHandle(null);
    event.currentTarget.releasePointerCapture(event.pointerId);
  };

  return (
    <ModalShell
      title="裁剪上传封面"
      onClose={onCancel}
      panelClassName="sm:max-w-5xl"
      overlayClassName="z-[120]"
      disableClose={isSubmitting}
    >
      <div className="grid gap-5 lg:grid-cols-[minmax(0,1.05fr)_20rem]">
        <div className="space-y-3">
          <div className="rounded-2xl border border-white/8 bg-black/18 p-2 sm:p-3">
            <PlatformMediaFrame
              ref={previewRef}
              src={imageDataUrl}
              alt="上传封面裁剪预览"
              fallbackLabel="封面"
              aspect="auto"
              surface="none"
              imageClassName="h-full w-full object-fill"
              imageProps={{ draggable: false }}
              overlayInteractive
              aria-label="封面裁剪操作区"
              className="relative mx-auto overflow-hidden rounded-xl border border-white/10 bg-black/40 select-none touch-none"
              style={editorPreviewStyle}
            >
              <div
                className={`absolute border-2 border-sky-200/95 shadow-[0_0_0_9999px_rgba(0,0,0,0.38)] outline outline-1 outline-black/35 ${
                  activeDragHandle === 'move'
                    ? 'cursor-grabbing'
                    : 'cursor-grab'
                }`}
                style={previewStyle}
                onPointerDown={(event) => beginCropDrag('move', event)}
                onPointerMove={updateCropDrag}
                onPointerUp={stopCropDrag}
                onPointerCancel={stopCropDrag}
              />
              <div
                className="pointer-events-none absolute border border-white/70"
                style={previewStyle}
              >
                <div className="absolute inset-x-0 top-1/3 border-t border-white/35" />
                <div className="absolute inset-x-0 top-2/3 border-t border-white/35" />
                <div className="absolute inset-y-0 left-1/3 border-l border-white/35" />
                <div className="absolute inset-y-0 left-2/3 border-l border-white/35" />
              </div>
              <div
                className="pointer-events-none absolute"
                style={previewStyle}
              >
                {COVER_CROP_RESIZE_HANDLES.map((handleConfig) => (
                  <button
                    key={handleConfig.handle}
                    type="button"
                    aria-label={handleConfig.label}
                    disabled={isSubmitting}
                    className={`pointer-events-auto absolute z-10 h-10 w-10 rounded-full border border-white/15 bg-black/5 p-0 disabled:cursor-not-allowed disabled:opacity-45 sm:h-9 sm:w-9 ${handleConfig.className}`}
                    onPointerDown={(event) =>
                      beginCropDrag(handleConfig.handle, event)
                    }
                    onPointerMove={updateCropDrag}
                    onPointerUp={stopCropDrag}
                    onPointerCancel={stopCropDrag}
                  >
                    <span
                      className={`absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-sky-300 shadow-[0_0_0_3px_rgba(2,132,199,0.32)] ${handleConfig.dotClassName}`}
                    />
                  </button>
                ))}
              </div>
            </PlatformMediaFrame>
          </div>
        </div>

        <div className="space-y-4">
          <div className="overflow-hidden rounded-2xl border border-white/8 bg-black/20 p-2">
            <PlatformMediaFrame
              src={imageDataUrl}
              alt="上传封面裁剪结果预览"
              fallbackLabel="封面"
              aspect="landscape"
              surface="none"
              className="rounded-xl bg-black/30"
              imageClassName="absolute max-w-none object-fill"
              imageProps={{
                draggable: false,
                style: outputPreviewStyle,
              }}
            />
          </div>
          <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
            <ActionButton
              label="取消"
              onClick={onCancel}
              disabled={isSubmitting}
            />
            <ActionButton
              label={isSubmitting ? '正在保存...' : '确认裁剪并上传'}
              onClick={() => onConfirm(cropRect)}
              tone="sky"
              disabled={isSubmitting}
            />
          </div>
        </div>
      </div>
    </ModalShell>
  );
}

function CoverImageGenerationModal({
  profile,
  onApply,
  onClose,
}: {
  profile: CustomWorldProfile;
  onApply: (result: CustomWorldCoverAssetResult) => void;
  onClose: () => void;
}) {
  const initialPresentation = useMemo(
    () => resolveCustomWorldCoverPresentation(profile),
    [profile],
  );
  const [userPrompt, setUserPrompt] = useDraft(profile.summary || profile.name);
  const [referenceImageSrc, setReferenceImageSrc] = useState('');
  const [isGenerating, setIsGenerating] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [latestResult, setLatestResult] =
    useState<CustomWorldCoverAssetResult | null>(null);
  const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
  const referenceImageInputId = useId();

  const previewImageSrc =
    latestResult?.imageSrc || initialPresentation.imageSrc;
  const openingAct = profile.sceneChapterBlueprints?.[0]?.acts?.[0] ?? null;
  const selectedCharacterRoleIds =
    profile.cover?.sourceType === 'default'
      ? profile.cover.characterRoleIds
      : buildDefaultCustomWorldCoverProfile(profile).characterRoleIds;
  const selectedRoleLabels = profile.playableNpcs
    .filter((role) => selectedCharacterRoleIds?.includes(role.id))
    .map((role) => role.name)
    .filter(Boolean);

  const handleReferenceImageChange = async (
    event: ChangeEvent<HTMLInputElement>,
  ) => {
    const file = event.target.files?.[0];
    event.currentTarget.value = '';
    if (!file) {
      return;
    }

    try {
      const dataUrl = await readImageFileAsDataUrl(file);
      setReferenceImageSrc(dataUrl);
      setError(null);
    } catch (uploadError) {
      setError(
        uploadError instanceof Error
          ? uploadError.message
          : '参考图读取失败，请重试。',
      );
    }
  };

  const handleRequestClose = () => {
    if (isGenerating) {
      return;
    }
    if (latestResult) {
      setIsExitConfirmOpen(true);
      return;
    }
    onClose();
  };

  const handleGenerate = async () => {
    if (!userPrompt.trim()) {
      setError('请先补一句你想要的封面氛围。');
      return;
    }

    setIsGenerating(true);
    setError(null);

    try {
      const result = await generateCustomWorldCoverImage({
        profile,
        userPrompt,
        referenceImageSrc,
        characterRoleIds: selectedCharacterRoleIds,
        size: FIXED_COVER_IMAGE_SIZE,
      });
      setLatestResult(result);
    } catch (generationError) {
      setError(
        generationError instanceof Error
          ? generationError.message
          : '作品封面生成失败，请稍后重试。',
      );
    } finally {
      setIsGenerating(false);
    }
  };

  const handleSave = () => {
    if (!latestResult || isGenerating) {
      return;
    }
    onApply(latestResult);
    onClose();
  };

  return (
    <>
      <ModalShell
        title="AI 生成作品封面"
        onClose={handleRequestClose}
        panelClassName="sm:max-w-5xl"
        overlayClassName="z-[99]"
        disableClose={isGenerating}
      >
        <div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
          <div className="space-y-4">
            <Field label="封面氛围">
              <TextArea
                value={userPrompt}
                onChange={(value) => setUserPrompt(value)}
                rows={5}
                placeholder="例如：海雾压进旧码头，主角站在残灯与潮水之间，整体像一张正式 RPG 作品封面。"
              />
            </Field>

            <div className="rounded-2xl border border-white/8 bg-black/18 px-4 py-4 text-xs leading-6 text-zinc-300">
              {openingAct?.title
                ? `系统会自动带入开局第一幕「${openingAct.title}」的场景素材。`
                : '系统会自动带入当前世界的开局场景素材。'}
              {selectedRoleLabels.length > 0
                ? ` 当前默认出镜角色：${selectedRoleLabels.join('、')}。`
                : ''}
            </div>

            <Field label="参考图（可选）">
              <div className="space-y-3">
                <PlatformUploadTile
                  asChild="label"
                  htmlFor={referenceImageInputId}
                  label="上传封面参考图"
                  hint="png / jpg / webp"
                  size="compact"
                  surface="editorDark"
                  disabled={isGenerating}
                />
                <input
                  id={referenceImageInputId}
                  type="file"
                  accept="image/png,image/jpeg,image/webp"
                  disabled={isGenerating}
                  onChange={(event) => {
                    void handleReferenceImageChange(event);
                  }}
                  className="sr-only"
                />
                {referenceImageSrc ? (
                  <PlatformUploadPreviewCard
                    layout="inline"
                    surface="editorDark"
                    imageSrc={referenceImageSrc}
                    imageAlt="封面参考图"
                    caption="已载入封面参考图"
                    removeLabel="移除封面参考图"
                    onRemove={() => setReferenceImageSrc('')}
                    disabled={isGenerating}
                    imageShellClassName="!h-16 !w-24 rounded-xl"
                  />
                ) : null}
              </div>
            </Field>
          </div>

          <div className="space-y-4">
            <div className="rounded-2xl border border-white/8 bg-black/18 p-2.5">
              <CustomWorldCoverArtwork
                imageSrc={previewImageSrc}
                title={profile.name}
                fallbackLabel={profile.name.slice(0, 4) || '封面'}
                renderMode={
                  latestResult ? 'image' : initialPresentation.renderMode
                }
                characterImageSrcs={
                  latestResult ? [] : initialPresentation.characterImageSrcs
                }
                className="aspect-[16/9] max-h-[14rem] rounded-2xl"
              />
            </div>

            {latestResult ? (
              <PlatformStatusMessage
                tone="success"
                surface="tinted"
                size="md"
                className={RPG_EDITOR_STATUS_SUCCESS_CLASS_NAME}
              >
                已生成完毕，保存后将替换当前作品封面。
              </PlatformStatusMessage>
            ) : null}

            {error ? (
              <PlatformStatusMessage
                tone="error"
                surface="tinted"
                size="md"
                className={RPG_EDITOR_STATUS_ERROR_CLASS_NAME}
              >
                {error}
              </PlatformStatusMessage>
            ) : null}

            <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
              <ActionButton
                label="保存"
                onClick={handleSave}
                disabled={!latestResult || isGenerating}
              />
              <ActionButton
                label={
                  isGenerating
                    ? '正在生成...'
                    : latestResult
                      ? '重新生成'
                      : '开始生成'
                }
                onClick={() => {
                  void handleGenerate();
                }}
                tone="sky"
                disabled={isGenerating}
              />
            </div>
          </div>
        </div>
      </ModalShell>

      {isExitConfirmOpen ? (
        <UnifiedConfirmDialog
          open
          title="确认退出"
          onClose={() => setIsExitConfirmOpen(false)}
          overlayClassName="z-[140]"
          panelClassName="platform-remap-surface rounded-[1.5rem]"
          confirmLabel="确认退出"
          cancelLabel="继续编辑"
          showCancel
          onConfirm={() => {
            setIsExitConfirmOpen(false);
            onClose();
          }}
        >
          当前生成结果还没有保存，确认退出吗？
        </UnifiedConfirmDialog>
      ) : null}
    </>
  );
}

export function WorldCoverEditor({
  profile,
  onSaveProfile,
  onClose,
}: {
  profile: CustomWorldProfile;
  onSaveProfile: (profile: CustomWorldProfile) => void;
  onClose: () => void;
}) {
  const [draftCover, setDraftCover] = useDraft(
    profile.cover ?? buildDefaultCustomWorldCoverProfile(profile),
  );
  const [uploadError, setUploadError] = useState<string | null>(null);
  const [isUploading, setIsUploading] = useState(false);
  const [isGenerating, setIsGenerating] = useState(false);
  const [pendingUploadImageDataUrl, setPendingUploadImageDataUrl] =
    useState('');
  const [pendingUploadImageSize, setPendingUploadImageSize] = useState<{
    width: number;
    height: number;
  } | null>(null);
  const coverUploadInputId = useId();
  const previewProfile = useMemo(
    () => ({
      ...profile,
      cover: draftCover,
    }),
    [draftCover, profile],
  );
  const previewPresentation = useMemo(
    () => resolveCustomWorldCoverPresentation(previewProfile),
    [previewProfile],
  );

  const handleUploadCover = async (event: ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.currentTarget.value = '';
    if (!file) {
      return;
    }

    try {
      if (file.size > COVER_IMAGE_MAX_UPLOAD_BYTES) {
        setUploadError('上传封面原图不能超过 10 MB。');
        return;
      }

      const imageDataUrl = await readImageFileAsDataUrl(file);
      const imageSize = await loadImageDimensionsFromDataUrl(imageDataUrl);
      setPendingUploadImageDataUrl(imageDataUrl);
      setPendingUploadImageSize(imageSize);
      setUploadError(null);
    } catch (uploadErrorValue) {
      setUploadError(
        uploadErrorValue instanceof Error
          ? uploadErrorValue.message
          : '上传作品封面失败，请稍后重试。',
      );
    }
  };

  const handleConfirmUploadCrop = async (
    cropRect: CustomWorldCoverCropRect,
  ) => {
    if (!pendingUploadImageDataUrl) {
      return;
    }

    setIsUploading(true);
    setUploadError(null);
    try {
      const result = await uploadCustomWorldCoverImage({
        profileId: profile.id,
        worldName: profile.name,
        imageDataUrl: pendingUploadImageDataUrl,
        cropRect,
      });
      setDraftCover(buildGeneratedCoverProfile(result));
      setPendingUploadImageDataUrl('');
      setPendingUploadImageSize(null);
    } catch (uploadErrorValue) {
      setUploadError(
        uploadErrorValue instanceof Error
          ? uploadErrorValue.message
          : '上传作品封面失败，请稍后重试。',
      );
    } finally {
      setIsUploading(false);
    }
  };

  return (
    <>
      <ModalShell
        title="编辑作品封面"
        onClose={onClose}
        panelClassName="sm:max-w-3xl"
      >
        <div className="grid gap-4 md:grid-cols-[minmax(0,0.95fr)_minmax(17rem,1.05fr)]">
          <div className="rounded-2xl border border-white/8 bg-black/18 p-2.5">
            <CustomWorldCoverArtwork
              imageSrc={previewPresentation.imageSrc}
              title={profile.name}
              fallbackLabel={profile.name.slice(0, 4) || '封面'}
              renderMode={previewPresentation.renderMode}
              characterImageSrcs={previewPresentation.characterImageSrcs}
              className="aspect-[16/9] max-h-[13rem] rounded-2xl"
            />
          </div>

          <div className="space-y-4">
            <div className="flex flex-wrap gap-3">
              <PlatformPillBadge tone="darkNeutral" size="xs">
                {draftCover.sourceType === 'uploaded'
                  ? '当前为上传封面'
                  : draftCover.sourceType === 'generated'
                    ? '当前为 AI 封面'
                    : '当前为默认封面'}
              </PlatformPillBadge>
            </div>

            <PlatformUploadTile
              asChild="label"
              htmlFor={coverUploadInputId}
              label="上传封面"
              hint="支持 png、jpg、webp。上传后会先裁剪成 16:9，再保存成封面。"
              icon={null}
              size="panel"
              surface="editorDark"
              disabled={isUploading}
            />
            <input
              id={coverUploadInputId}
              type="file"
              accept="image/png,image/jpeg,image/webp"
              disabled={isUploading}
              onChange={(event) => {
                void handleUploadCover(event);
              }}
              className="sr-only"
            />

            <div className="flex flex-wrap gap-3">
              <ActionButton
                label="AI 生成"
                onClick={() => setIsGenerating(true)}
                tone="sky"
              />
              <ActionButton
                label="重置为默认"
                onClick={() =>
                  setDraftCover(buildDefaultCustomWorldCoverProfile(profile))
                }
                disabled={draftCover.sourceType === 'default'}
              />
            </div>

            {uploadError ? (
              <PlatformStatusMessage
                tone="error"
                surface="tinted"
                size="md"
                className={RPG_EDITOR_STATUS_ERROR_CLASS_NAME}
              >
                {uploadError}
              </PlatformStatusMessage>
            ) : null}

            <SaveBar
              onClose={onClose}
              onSave={() => {
                onSaveProfile({
                  ...profile,
                  cover: draftCover,
                });
                onClose();
              }}
            />
          </div>
        </div>
      </ModalShell>

      {isGenerating ? (
        <CoverImageGenerationModal
          profile={previewProfile}
          onApply={(result) => {
            setDraftCover(buildGeneratedCoverProfile(result));
            setUploadError(null);
            setIsGenerating(false);
          }}
          onClose={() => setIsGenerating(false)}
        />
      ) : null}

      {pendingUploadImageDataUrl && pendingUploadImageSize ? (
        <CoverUploadCropModal
          imageDataUrl={pendingUploadImageDataUrl}
          imageSize={pendingUploadImageSize}
          isSubmitting={isUploading}
          onCancel={() => {
            if (isUploading) {
              return;
            }
            setPendingUploadImageDataUrl('');
            setPendingUploadImageSize(null);
          }}
          onConfirm={(cropRect) => {
            void handleConfirmUploadCrop(cropRect);
          }}
        />
      ) : null}

      {isUploading ? (
        <PortalCompactDialogShell
          title="上传封面中"
          onClose={() => {}}
          disableClose
        >
          <PlatformSubpanel
            as="div"
            surface="darkSky"
            radius="sm"
            padding="md"
            className="text-sm leading-6"
          >
            正在保存封面资源，请稍候。
          </PlatformSubpanel>
        </PortalCompactDialogShell>
      ) : null}
    </>
  );
}

export function SaveBar({
  onClose,
  onSave,
  extraAction,
  showClose = true,
}: {
  onClose: () => void;
  onSave: () => void;
  extraAction?: ReactNode;
  showClose?: boolean;
}) {
  return (
    <div className="sticky bottom-0 z-10 -mx-4 border-t border-[var(--platform-surface-border)] bg-[color:var(--platform-surface)]/95 px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.2rem)] pt-2 shadow-[0_-16px_28px_rgba(15,23,42,0.08)] backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:shadow-none sm:backdrop-blur-0">
      <div
        className={`flex flex-col gap-3 ${
          extraAction
            ? 'sm:flex-row sm:items-center sm:justify-between'
            : 'sm:flex-row sm:justify-end'
        }`}
      >
        {extraAction ? (
          <div className="flex flex-col gap-3 sm:flex-row">{extraAction}</div>
        ) : null}
        <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
          {showClose ? (
            <button
              type="button"
              onClick={onClose}
              className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
            >
              取消
            </button>
          ) : null}
          <ActionButton label="保存修改" onClick={onSave} tone="sky" />
        </div>
      </div>
    </div>
  );
}

export function SectionPanel({
  title,
  subtitle,
  actions,
  children,
}: {
  title: string;
  subtitle?: string;
  actions?: ReactNode;
  children: ReactNode;
}) {
  return (
    <PlatformSubpanel
      surface="dark"
      radius="md"
      padding="md"
      title={
        <div>
          <div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
            {title}
          </div>
          {subtitle ? (
            <div className="mt-2 text-sm leading-6 text-zinc-400">
              {subtitle}
            </div>
          ) : null}
        </div>
      }
      titleVariant="strong"
      actions={actions}
      headerClassName="flex-wrap items-start"
      titleClassName="text-[11px] font-bold tracking-[0.16em] text-zinc-300"
      bodyClassName="mt-4 space-y-3"
    >
      {children}
    </PlatformSubpanel>
  );
}

function buildRolePreviewCharacter(
  role: CustomWorldPlayableNpc | CustomWorldNpc,
): Character | null {
  const portrait = role.imageSrc;

  if (!portrait) {
    return null;
  }

  return {
    id: role.id,
    name: role.name,
    title: role.title,
    description: role.description,
    backstory: role.backstory,
    avatar: portrait,
    portrait,
    assetFolder: 'custom-world',
    assetVariant: 'generated',
    generatedVisualAssetId: role.generatedVisualAssetId,
    generatedAnimationSetId: role.generatedAnimationSetId,
    animationMap: role.animationMap,
    attributes: { strength: 0, agility: 0, intelligence: 0, spirit: 0 },
    personality: role.personality,
    skills: [],
    adventureOpenings: {},
  } as Character;
}

function BackstoryRevealEditor({
  value,
  onNotice,
  onChange,
}: {
  value: CustomWorldPlayableNpc['backstoryReveal'];
  onNotice: (message: string) => void;
  onChange: (value: CustomWorldPlayableNpc['backstoryReveal']) => void;
}) {
  const updateChapter = (
    index: number,
    updater: (
      chapter: CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
    ) => CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
  ) => {
    onChange({
      ...value,
      chapters: value.chapters.map((chapter, chapterIndex) =>
        chapterIndex === index ? updater(chapter) : chapter,
      ),
    });
  };

  const addChapter = () => {
    onChange({
      ...value,
      chapters: [
        ...value.chapters,
        createBackstoryChapterDraft('custom-role', value.chapters.length),
      ],
    });
  };

  const removeChapter = (index: number) => {
    if (value.chapters.length <= 1) {
      onNotice('至少保留一个背景章节。');
      return;
    }

    onChange({
      ...value,
      chapters: value.chapters.filter(
        (_chapter, chapterIndex) => chapterIndex !== index,
      ),
    });
  };

  return (
    <SectionPanel
      title="背景故事"
      actions={
        <ActionButton label="新增章节" onClick={addChapter} tone="sky" />
      }
    >
      <Field label="对外摘要">
        <TextArea
          value={value.publicSummary}
          onChange={(nextValue) =>
            onChange({
              ...value,
              publicSummary: nextValue,
            })
          }
          rows={3}
        />
      </Field>
      {value.chapters.map((chapter, index) => (
        <div
          key={`${chapter.id}-${index}`}
          className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
        >
          <div className="mb-3 flex items-center justify-between gap-3">
            <div className="text-sm font-semibold text-white">
              背景故事片段 #{index + 1}
            </div>
            <ActionButton
              label="删除章节"
              onClick={() => removeChapter(index)}
            />
          </div>
          <Field label="章节标题">
            <TextInput
              value={chapter.title}
              onChange={(nextValue) =>
                updateChapter(index, (current) => ({
                  ...current,
                  title: nextValue,
                }))
              }
            />
          </Field>
          <Field label="解锁好感">
            <TextInput
              type="number"
              value={chapter.affinityRequired}
              onChange={(nextValue) =>
                updateChapter(index, (current) => ({
                  ...current,
                  affinityRequired: clampInitialAffinity(
                    nextValue,
                    current.affinityRequired,
                  ),
                }))
              }
            />
          </Field>
          <Field
            label={
              <LabelWithInfo
                label="章节提示"
                info="作用：作为该段背景尚未完全公开时的提示线索，帮助系统在关系推进或试探阶段提前埋钩子。是否展示给用户：会。展示位置：角色相关结果页、关系推进展示，以及后续部分剧情中的悬念化提示。"
              />
            }
          >
            <TextArea
              value={chapter.teaser}
              onChange={(nextValue) =>
                updateChapter(index, (current) => ({
                  ...current,
                  teaser: nextValue,
                }))
              }
              rows={2}
            />
          </Field>
          <Field
            label={
              <LabelWithInfo
                label="章节内容"
                info="作用：作为该段背景真正解锁后的完整内容，提供系统后续剧情、关系推进和角色理解所需的核心信息。是否展示给用户：会。展示位置：对应背景片段被解锁后的角色内容面板，以及相关剧情正式揭露时。"
              />
            }
          >
            <TextArea
              value={chapter.content}
              onChange={(nextValue) =>
                updateChapter(index, (current) => ({
                  ...current,
                  content: nextValue,
                }))
              }
              rows={3}
            />
          </Field>
          <Field
            label={
              <LabelWithInfo
                label="剧情引用摘要"
                info="作用：给叙事系统提供一段可被剧情直接抽取引用的压缩摘要，用来在剧情里自然提到这段背景，而不是整段照搬。是否展示给用户：通常不直接完整展示。展示位置：主要用于后续剧情文案、角色对话、事件摘要中的引用表达。"
              />
            }
          >
            <TextArea
              value={chapter.contextSnippet}
              onChange={(nextValue) =>
                updateChapter(index, (current) => ({
                  ...current,
                  contextSnippet: nextValue,
                }))
              }
              rows={2}
            />
          </Field>
        </div>
      ))}
    </SectionPanel>
  );
}

function RoleRelationsEditor({
  value,
  onChange,
  roleOptions,
  labelSeed,
}: {
  value: CustomWorldRoleRelation[];
  onChange: (value: CustomWorldRoleRelation[]) => void;
  roleOptions: Array<{ value: string; label: string }>;
  labelSeed: string;
}) {
  const updateRelation = (
    index: number,
    updater: (relation: CustomWorldRoleRelation) => CustomWorldRoleRelation,
  ) => {
    const nextRelations = value.map((relation, relationIndex) =>
      relationIndex === index ? updater(relation) : relation,
    );
    onChange(nextRelations);
  };

  return (
    <SectionPanel
      title="与其他角色的关系"
      actions={
        <ActionButton
          label="新增关系"
          onClick={() =>
            onChange([
              ...value,
              createRoleRelationDraft(labelSeed, value.length),
            ])
          }
          tone="sky"
        />
      }
    >
      {value.length > 0 ? (
        value.map((relation, index) => (
          <div
            key={`${relation.id}-${index}`}
            className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
          >
            <div className="mb-3 flex items-center justify-between gap-3">
              <div className="text-sm font-semibold text-white">
                关系 #{index + 1}
              </div>
              <ActionButton
                label="删除关系"
                onClick={() =>
                  onChange(
                    value.filter(
                      (_relation, relationIndex) => relationIndex !== index,
                    ),
                  )
                }
              />
            </div>
            <Field label="关联角色">
              <SelectField
                value={relation.targetRoleId}
                onChange={(nextValue) =>
                  updateRelation(index, (current) => ({
                    ...current,
                    targetRoleId: nextValue,
                  }))
                }
                options={[{ value: '', label: '未指定' }, ...roleOptions]}
              />
            </Field>
            <Field label="关系文本">
              <TextArea
                value={relation.summary}
                onChange={(nextValue) =>
                  updateRelation(index, (current) => ({
                    ...current,
                    summary: nextValue,
                  }))
                }
                rows={2}
                placeholder="例如：她与沈砺曾共同守过旧灯塔，但在沉船事件后分道扬镳。"
              />
            </Field>
          </div>
        ))
      ) : (
        <EditorEmptyState>还没有配置与其他角色的关系。</EditorEmptyState>
      )}
    </SectionPanel>
  );
}

function RoleSkillEditorModal({
  role,
  skill,
  onSave,
  onClose,
}: {
  role: CustomWorldPlayableNpc | CustomWorldNpc;
  skill: CustomWorldRoleSkill;
  onSave: (skill: CustomWorldRoleSkill) => void;
  onClose: () => void;
}) {
  const [draft, setDraft] = useDraft(skill);
  const [status, setStatus] = useState<string | null>(null);
  const [isGenerating, setIsGenerating] = useState(false);
  const previewCharacter = useMemo(() => {
    const base = buildRolePreviewCharacter(role);
    if (!base || !draft.actionPreviewConfig) {
      return base;
    }

    return {
      ...base,
      animationMap: {
        ...(base.animationMap ?? {}),
        [AnimationState.ATTACK]: draft.actionPreviewConfig,
      },
    } satisfies Character;
  }, [draft.actionPreviewConfig, role]);
  const actionPreviewFrameStyle = useMemo(
    () => getAnimationPreviewFrameStyle(draft.actionPreviewConfig, 320),
    [draft.actionPreviewConfig],
  );

  const handleGenerateAction = async () => {
    if (!role.imageSrc || !role.generatedVisualAssetId) {
      setStatus('请先为角色生成并保存主图后，再生成技能动作。');
      return;
    }

    setIsGenerating(true);
    setStatus(null);

    try {
      const promptText =
        draft.actionPromptText?.trim() ||
        buildSkillActionPrompt({
          role,
          skill: draft,
        });
      const actionKey = `skill-${draft.id}`;
      const templateId = inferSkillActionTemplateId(draft);
      const generationResult = await generateCharacterAnimationDraft({
        characterId: role.id,
        strategy: 'image-to-video',
        animation: actionKey,
        promptText,
        characterBriefText: [
          role.name,
          role.title,
          role.role,
          role.description,
          role.backstory,
          role.personality,
          role.motivation,
        ]
          .filter(Boolean)
          .join(' / '),
        actionTemplateId: templateId,
        visualSource: role.imageSrc,
        referenceImageDataUrls: [],
        referenceVideoDataUrls: [],
        lastFrameImageDataUrl: role.imageSrc,
        frameCount: 8,
        fps: 10,
        durationSeconds: 4,
        loop: false,
        useChromaKey: true,
        resolution: '480p',
        ratio: '1:1',
        imageSequenceModel: 'wan2.7-image-pro',
        videoModel: 'doubao-seedance-2-0-fast-260128',
        referenceVideoModel: 'wan2.7-r2v',
        motionTransferModel: 'wan2.2-animate-move',
      } satisfies CharacterAnimationGenerationPayload);

      if (generationResult.strategy !== 'image-to-video') {
        throw new Error('当前技能动作预览仅支持图生视频生成。');
      }

      const publishResult = await publishCharacterAnimationAssets({
        characterId: role.id,
        visualAssetId: role.generatedVisualAssetId,
        animations: {
          [actionKey]: {
            framesDataUrls: [],
            fps: 10,
            loop: false,
            frameWidth: 192,
            frameHeight: 256,
            frameCount: 8,
            applyChromaKey: true,
            previewVideoPath: generationResult.previewVideoPath,
          },
        },
        updateCharacterOverride: false,
      });

      setDraft((current) => ({
        ...current,
        actionPromptText: promptText,
        actionPreviewConfig: publishResult.animationMap[
          actionKey
        ] as CharacterAnimationConfig,
      }));
      setStatus('技能动作预览已更新。');
    } catch (error) {
      setStatus(error instanceof Error ? error.message : '技能动作生成失败。');
    } finally {
      setIsGenerating(false);
    }
  };

  return (
    <ModalShell
      title={`编辑技能：${skill.name || '未命名技能'}`}
      onClose={onClose}
      panelClassName="sm:max-w-3xl"
      overlayClassName="z-[130]"
    >
      <div className="space-y-4">
        <div className="rounded-2xl border border-white/8 bg-black/20 p-4">
          <div className="flex min-h-[20rem] items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3">
            {previewCharacter && draft.actionPreviewConfig ? (
              <div style={actionPreviewFrameStyle}>
                <CharacterAnimator
                  state={AnimationState.ATTACK}
                  character={previewCharacter}
                  className="h-full w-full"
                />
              </div>
            ) : role.imageSrc ? (
              <PlatformMediaFrame
                src={role.imageSrc}
                alt={role.name}
                fallbackLabel="技能动作预览"
                aspect="auto"
                surface="none"
                className="flex h-full w-full items-center justify-center rounded-none"
                imageClassName="max-h-40 w-full object-contain"
              />
            ) : (
              <div className="text-sm text-zinc-500">暂无技能动作预览</div>
            )}
          </div>
        </div>
        <Field label="技能名称">
          <TextInput
            value={draft.name}
            onChange={(nextValue) =>
              setDraft((current) => ({ ...current, name: nextValue }))
            }
          />
        </Field>
        <Field label="技能摘要">
          <TextArea
            value={draft.summary}
            onChange={(nextValue) =>
              setDraft((current) => ({ ...current, summary: nextValue }))
            }
            rows={3}
          />
        </Field>
        <Field label="技能动作提示词">
          <TextArea
            value={
              draft.actionPromptText ||
              buildSkillActionPrompt({
                role,
                skill: draft,
              })
            }
            onChange={(nextValue) =>
              setDraft((current) => ({
                ...current,
                actionPromptText: nextValue,
              }))
            }
            rows={4}
          />
        </Field>
        {status ? (
          <div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
            {status}
          </div>
        ) : null}
        <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
          <ActionButton label="取消" onClick={onClose} />
          <ActionButton
            label={isGenerating ? '生成中...' : '重新生成技能动作'}
            onClick={() => {
              void handleGenerateAction();
            }}
            disabled={isGenerating}
            tone="sky"
          />
          <ActionButton
            label="保存"
            onClick={() => {
              onSave(draft);
              onClose();
            }}
            tone="sky"
          />
        </div>
      </div>
    </ModalShell>
  );
}

function SkillListEditor({
  role,
  value,
  onChange,
  labelSeed,
}: {
  role: CustomWorldPlayableNpc | CustomWorldNpc;
  value: CustomWorldPlayableNpc['skills'];
  onChange: (value: CustomWorldPlayableNpc['skills']) => void;
  labelSeed: string;
}) {
  const [editingSkillIndex, setEditingSkillIndex] = useState<number | null>(
    null,
  );
  const rolePreviewCharacter = useMemo(
    () => buildRolePreviewCharacter(role),
    [role],
  );

  return (
    <SectionPanel
      title="技能"
      actions={
        <ActionButton
          label="新增技能"
          onClick={() =>
            onChange([...value, createRoleSkillDraft(labelSeed, value.length)])
          }
          tone="sky"
        />
      }
    >
      {value.length > 0 ? (
        value.map((skill, index) => {
          const previewCharacter =
            rolePreviewCharacter && skill.actionPreviewConfig
              ? ({
                  ...rolePreviewCharacter,
                  animationMap: {
                    ...(rolePreviewCharacter.animationMap ?? {}),
                    [AnimationState.ATTACK]: skill.actionPreviewConfig,
                  },
                } satisfies Character)
              : rolePreviewCharacter;

          return (
            <button
              key={`${skill.id}-${index}`}
              type="button"
              onClick={() => setEditingSkillIndex(index)}
              className="w-full rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-left transition-colors hover:border-white/18"
            >
              <div className="grid gap-3 sm:grid-cols-[6.5rem_minmax(0,1fr)_auto] sm:items-center">
                <div className="flex h-24 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/25 p-2">
                  {previewCharacter && skill.actionPreviewConfig ? (
                    <div className="h-20 w-20">
                      <CharacterAnimator
                        state={AnimationState.ATTACK}
                        character={previewCharacter}
                        className="h-full w-full"
                      />
                    </div>
                  ) : role.imageSrc ? (
                    <PlatformMediaFrame
                      src={role.imageSrc}
                      alt={skill.name}
                      fallbackLabel="技能预览"
                      aspect="auto"
                      surface="none"
                      className="flex h-full w-full items-center justify-center rounded-none"
                      imageClassName="max-h-20 w-full object-contain"
                    />
                  ) : (
                    <div className="text-xs text-zinc-500">暂无预览</div>
                  )}
                </div>
                <div className="min-w-0">
                  <div className="text-sm font-semibold text-white">
                    {skill.name}
                  </div>
                  <div className="mt-2 line-clamp-2 text-sm leading-6 text-zinc-400">
                    {skill.summary || '点击补充技能摘要与技能动作。'}
                  </div>
                </div>
                <div className="flex flex-wrap gap-2 sm:justify-end">
                  <PlatformPillBadge
                    tone={
                      skill.actionPreviewConfig ? 'darkEmerald' : 'darkNeutral'
                    }
                    size="xxs"
                    className={`px-2.5 ${skill.actionPreviewConfig ? '' : 'text-zinc-400'}`}
                  >
                    {skill.actionPreviewConfig ? '动作已生成' : '待生成动作'}
                  </PlatformPillBadge>
                </div>
              </div>
            </button>
          );
        })
      ) : (
        <EditorEmptyState>还没有配置角色技能。</EditorEmptyState>
      )}
      {editingSkillIndex !== null && value[editingSkillIndex] ? (
        <RoleSkillEditorModal
          role={role}
          skill={value[editingSkillIndex]!}
          onSave={(nextSkill) =>
            onChange(
              value.map((skill, skillIndex) =>
                skillIndex === editingSkillIndex ? nextSkill : skill,
              ),
            )
          }
          onClose={() => setEditingSkillIndex(null)}
        />
      ) : null}
    </SectionPanel>
  );
}

function RoleAssetAppliedBadges({
  generatedVisualAssetId,
  generatedAnimationSetId,
  className,
}: {
  generatedVisualAssetId?: string;
  generatedAnimationSetId?: string;
  className?: string;
}) {
  if (!generatedVisualAssetId && !generatedAnimationSetId) {
    return null;
  }

  return (
    <div
      className={['flex flex-wrap gap-2', className].filter(Boolean).join(' ')}
    >
      {generatedVisualAssetId ? (
        <PlatformPillBadge tone="darkEmerald" size="xxs" className="px-2.5">
          已应用主图
        </PlatformPillBadge>
      ) : null}
      {generatedAnimationSetId ? (
        <PlatformPillBadge tone="darkAmber" size="xxs" className="px-2.5">
          已应用动作
        </PlatformPillBadge>
      ) : null}
    </div>
  );
}

function RoleInitialItemTagBadges({
  itemId,
  tags,
}: {
  itemId: string;
  tags: string[];
}) {
  if (tags.length === 0) {
    return null;
  }

  return (
    <div className="mt-2 flex flex-wrap gap-2">
      {tags.map((tag) => (
        <PlatformPillBadge
          key={`${itemId}-${tag}`}
          tone="darkNeutral"
          size="xxs"
          className="px-2 py-1 text-zinc-300"
        >
          {tag}
        </PlatformPillBadge>
      ))}
    </div>
  );
}

function RoleInitialItemEditorModal({
  item,
  onSave,
  onClose,
}: {
  item: CustomWorldRoleInitialItem;
  onSave: (item: CustomWorldRoleInitialItem) => void;
  onClose: () => void;
}) {
  const [draft, setDraft] = useDraft(item);
  const [assetPaths, setAssetPaths] = useState<string[]>([]);
  const [status, setStatus] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;

    void fetchJson<{ assetPaths: string[] }>(
      EDITOR_ITEM_CATALOG_API_PATH,
      '读取物品图标目录失败',
    )
      .then((result) => {
        if (!cancelled) {
          setAssetPaths(result.assetPaths ?? []);
        }
      })
      .catch((error) => {
        if (!cancelled) {
          setStatus(
            error instanceof Error ? error.message : '读取物品图标目录失败。',
          );
        }
      });

    return () => {
      cancelled = true;
    };
  }, []);

  const handleRegenerateIcon = () => {
    if (assetPaths.length === 0) {
      setStatus('当前没有可用的物品图标素材。');
      return;
    }

    const normalizedCategory = draft.category.trim();
    const categoryKeywords: Record<string, string[]> = {
      武器: ['weapon', 'sword', 'axe', 'bow', 'wand', 'staff', 'dagger'],
      护甲: ['armor', 'helmet', 'shield', 'robe', 'boots', 'cloak'],
      饰品: ['ring', 'amulet', 'gem', 'relic', 'necklace'],
      消耗品: ['potion', 'bottle', 'food', 'mushroom', 'apple', 'bandage'],
      材料: ['ore', 'stone', 'wood', 'leaf', 'flower', 'material'],
      稀有品: ['scroll', 'book', 'crystal', 'magic', 'bag'],
      专属物品: ['artifact', 'legend', 'treasure', 'relic'],
    };
    const pool = assetPaths.filter((assetPath) => {
      const lower = assetPath.toLowerCase();
      const keywords = categoryKeywords[normalizedCategory] ?? [];
      return (
        keywords.length === 0 ||
        keywords.some((keyword) => lower.includes(keyword))
      );
    });
    const candidates = pool.length > 0 ? pool : assetPaths;
    const currentIndex = candidates.findIndex(
      (entry) => `/${entry}` === (draft.iconSrc ?? ''),
    );
    const seed = hashText(
      [
        draft.name,
        draft.category,
        draft.description,
        draft.tags.join('|'),
      ].join('::'),
    );
    const nextIndex =
      currentIndex >= 0
        ? (currentIndex + 1) % candidates.length
        : seed % candidates.length;

    setDraft((current) => ({
      ...current,
      iconSrc: `/${candidates[nextIndex]!}`,
    }));
    setStatus('物品图标已更新。');
  };

  return (
    <ModalShell
      title={`编辑物品：${item.name || '未命名物品'}`}
      onClose={onClose}
      panelClassName="sm:max-w-3xl"
      overlayClassName="z-[130]"
    >
      <div className="space-y-4">
        <div
          className={`rounded-2xl border p-4 ${getItemRarityCardClass(draft.rarity)}`}
        >
          <div className="flex items-center gap-4">
            <div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3">
              {draft.iconSrc ? (
                <PixelIcon src={draft.iconSrc} className="h-12 w-12" />
              ) : (
                <div className="text-xs text-zinc-500">暂无图标</div>
              )}
            </div>
            <div className="min-w-0 flex-1">
              <div className="text-base font-semibold text-white">
                {draft.name}
              </div>
              <div className="mt-1 text-xs text-zinc-300">
                {getItemRarityLabel(draft.rarity)} / 数量 {draft.quantity}
              </div>
              <RoleInitialItemTagBadges itemId={draft.id} tags={draft.tags} />
            </div>
          </div>
        </div>
        <div className="flex justify-end">
          <ActionButton
            label="重新生成图标"
            onClick={handleRegenerateIcon}
            tone="sky"
          />
        </div>
        <Field label="名称">
          <TextInput
            value={draft.name}
            onChange={(nextValue) =>
              setDraft((current) => ({ ...current, name: nextValue }))
            }
          />
        </Field>
        <div className="grid gap-3 sm:grid-cols-2">
          <Field label="分类">
            <TextInput
              value={draft.category}
              onChange={(nextValue) =>
                setDraft((current) => ({ ...current, category: nextValue }))
              }
            />
          </Field>
          <Field label="稀有度">
            <SelectField
              value={draft.rarity}
              onChange={(nextValue) =>
                setDraft((current) => ({
                  ...current,
                  rarity: nextValue as ItemRarity,
                }))
              }
              options={ITEM_RARITY_OPTIONS}
            />
          </Field>
        </div>
        <Field label="数量">
          <TextInput
            type="number"
            value={draft.quantity}
            onChange={(nextValue) =>
              setDraft((current) => ({
                ...current,
                quantity: Math.max(
                  1,
                  parseOptionalNumber(nextValue) ?? current.quantity,
                ),
              }))
            }
          />
        </Field>
        <Field label="描述">
          <TextArea
            value={draft.description}
            onChange={(nextValue) =>
              setDraft((current) => ({ ...current, description: nextValue }))
            }
            rows={3}
          />
        </Field>
        <Field label="标签">
          <TextArea
            value={commaText(draft.tags)}
            onChange={(nextValue) =>
              setDraft((current) => ({
                ...current,
                tags: parseCommaText(nextValue),
              }))
            }
            rows={2}
          />
        </Field>
        {status ? (
          <div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
            {status}
          </div>
        ) : null}
        <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
          <ActionButton label="取消" onClick={onClose} />
          <ActionButton
            label="保存"
            onClick={() => {
              onSave(draft);
              onClose();
            }}
            tone="sky"
          />
        </div>
      </div>
    </ModalShell>
  );
}

function InitialItemsEditor({
  value,
  onChange,
  labelSeed,
}: {
  value: CustomWorldPlayableNpc['initialItems'];
  onChange: (value: CustomWorldPlayableNpc['initialItems']) => void;
  labelSeed: string;
}) {
  const [editingItemIndex, setEditingItemIndex] = useState<number | null>(null);

  return (
    <SectionPanel
      title="物品"
      actions={
        <ActionButton
          label="新增物品"
          onClick={() =>
            onChange([
              ...value,
              createRoleInitialItemDraft(labelSeed, value.length),
            ])
          }
          tone="sky"
        />
      }
    >
      {value.length > 0 ? (
        value.map((item, index) => (
          <button
            key={`${item.id}-${index}`}
            type="button"
            onClick={() => setEditingItemIndex(index)}
            className={`w-full rounded-2xl border px-3 py-3 text-left transition-colors hover:border-white/18 ${getItemRarityCardClass(item.rarity)}`}
          >
            <div className="grid gap-3 sm:grid-cols-[4.5rem_minmax(0,1fr)_auto] sm:items-center">
              <div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-2">
                {item.iconSrc ? (
                  <PixelIcon src={item.iconSrc} className="h-10 w-10" />
                ) : (
                  <div className="text-[10px] text-zinc-500">暂无图标</div>
                )}
              </div>
              <div className="min-w-0">
                <div className="text-sm font-semibold text-white">
                  {item.name}
                </div>
                <div className="mt-1 text-xs text-zinc-300">
                  {getItemRarityLabel(item.rarity)}
                </div>
                <RoleInitialItemTagBadges itemId={item.id} tags={item.tags} />
              </div>
              <div className="text-xs text-zinc-200">x{item.quantity}</div>
            </div>
          </button>
        ))
      ) : (
        <EditorEmptyState>还没有配置角色物品。</EditorEmptyState>
      )}
      {editingItemIndex !== null && value[editingItemIndex] ? (
        <RoleInitialItemEditorModal
          item={value[editingItemIndex]!}
          onSave={(nextItem) =>
            onChange(
              value.map((item, itemIndex) =>
                itemIndex === editingItemIndex ? nextItem : item,
              ),
            )
          }
          onClose={() => setEditingItemIndex(null)}
        />
      ) : null}
    </SectionPanel>
  );
}

export function WorldEditor({
  profile,
  onSave,
  onClose,
}: {
  profile: CustomWorldProfile;
  onSave: (profile: CustomWorldProfile) => void;
  onClose: () => void;
}) {
  const [draft, setDraft] = useDraft(profile);

  return (
    <ModalShell
      title="编辑世界信息"
      subtitle="修改后的内容会直接反映在结果页，并会作为进入世界前的最终档案。"
      onClose={onClose}
      panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
    >
      <div className="space-y-4">
        <Field label="世界名称">
          <TextInput
            value={draft.name}
            onChange={(value) =>
              setDraft((current) => ({ ...current, name: value }))
            }
          />
        </Field>
        <Field label="副标题">
          <TextInput
            value={draft.subtitle}
            onChange={(value) =>
              setDraft((current) => ({ ...current, subtitle: value }))
            }
          />
        </Field>
        <Field label="世界概述">
          <TextArea
            value={draft.summary}
            onChange={(value) =>
              setDraft((current) => ({ ...current, summary: value }))
            }
            rows={4}
          />
        </Field>
        <Field label="世界基调">
          <TextArea
            value={draft.tone}
            onChange={(value) =>
              setDraft((current) => ({ ...current, tone: value }))
            }
            rows={3}
          />
        </Field>
        <Field label="主线目标">
          <TextArea
            value={draft.playerGoal}
            onChange={(value) =>
              setDraft((current) => ({ ...current, playerGoal: value }))
            }
            rows={3}
          />
        </Field>
        <SaveBar
          onClose={onClose}
          onSave={() => {
            onSave(draft);
            onClose();
          }}
        />
      </div>
    </ModalShell>
  );
}

type FoundationDraft = Record<CustomWorldFoundationEntryId, string>;

const FOUNDATION_EDITOR_FIELDS: Array<{
  id: CustomWorldFoundationEntryId;
  label: string;
  rows: number;
}> = [
  { id: 'world-promise', label: '世界承诺', rows: 4 },
  { id: 'player-fantasy', label: '玩家幻想', rows: 4 },
  { id: 'theme-boundary', label: '主题边界', rows: 4 },
  { id: 'player-entry-point', label: '玩家切入口', rows: 4 },
  { id: 'core-conflict', label: '核心冲突', rows: 4 },
  { id: 'key-relationships', label: '关键关系', rows: 4 },
  { id: 'hidden-lines', label: '暗线与揭示', rows: 4 },
  { id: 'iconic-elements', label: '标志元素', rows: 4 },
];

function buildFoundationDraft(profile: CustomWorldProfile): FoundationDraft {
  const anchorContent = getCustomWorldFoundationAnchorContent(profile);

  return {
    'world-promise': anchorContent.worldPromise || '',
    'player-fantasy': anchorContent.playerFantasy || '',
    'theme-boundary': anchorContent.themeBoundary || '',
    'player-entry-point': anchorContent.playerEntryPoint || '',
    'core-conflict': anchorContent.coreConflict || '',
    'key-relationships': anchorContent.keyRelationships || '',
    'hidden-lines': anchorContent.hiddenLines || '',
    'iconic-elements': anchorContent.iconicElements || '',
  };
}

function splitCommaTags(value: string) {
  return value
    .split(/[、,，]/u)
    .map((item) => item.trim())
    .filter(Boolean);
}

function applyFoundationDraftToProfile(
  profile: CustomWorldProfile,
  draft: FoundationDraft,
): CustomWorldProfile {
  const worldPromiseTags = parseFoundationTagText(draft['world-promise']);
  const playerFantasyTags = parseFoundationTagText(draft['player-fantasy']);
  const themeBoundaryTags = parseFoundationTagText(draft['theme-boundary']);
  const playerEntryTags = parseFoundationTagText(draft['player-entry-point']);
  const coreConflictTags = parseFoundationTagText(draft['core-conflict']);
  const creatorIntent =
    profile.creatorIntent ?? createEmptyCustomWorldCreatorIntent('freeform');

  return {
    ...profile,
    summary: worldPromiseTags[0] || profile.summary,
    subtitle: worldPromiseTags[1] || profile.subtitle,
    tone: themeBoundaryTags[0] || profile.tone,
    playerGoal: playerFantasyTags[1] || profile.playerGoal,
    worldHook: worldPromiseTags[0] || profile.worldHook || null,
    playerPremise: playerFantasyTags[0] || profile.playerPremise || null,
    coreConflicts: coreConflictTags[0]
      ? splitCommaTags(coreConflictTags[0])
      : profile.coreConflicts,
    creatorIntent: {
      ...creatorIntent,
      worldHook: worldPromiseTags[0] || creatorIntent.worldHook,
      playerPremise: playerFantasyTags[0] || creatorIntent.playerPremise,
      openingSituation: playerEntryTags[1] || creatorIntent.openingSituation,
      themeKeywords: themeBoundaryTags[0]
        ? splitCommaTags(themeBoundaryTags[0])
        : creatorIntent.themeKeywords,
      toneDirectives: themeBoundaryTags[1]
        ? splitCommaTags(themeBoundaryTags[1])
        : creatorIntent.toneDirectives,
      coreConflicts: coreConflictTags[0]
        ? splitCommaTags(coreConflictTags[0])
        : creatorIntent.coreConflicts,
      iconicElements: draft['iconic-elements'].trim()
        ? splitCommaTags(draft['iconic-elements'])
        : creatorIntent.iconicElements,
      forbiddenDirectives: themeBoundaryTags[2]
        ? splitCommaTags(
            themeBoundaryTags[2].replace(/^避免[:：]\s*/u, '').trim(),
          )
        : creatorIntent.forbiddenDirectives,
    },
    anchorContent: {
      worldPromise: draft['world-promise'].trim() || null,
      playerFantasy: draft['player-fantasy'].trim() || null,
      themeBoundary: draft['theme-boundary'].trim() || null,
      playerEntryPoint: draft['player-entry-point'].trim() || null,
      coreConflict: draft['core-conflict'].trim() || null,
      keyRelationships: draft['key-relationships'].trim() || null,
      hiddenLines: draft['hidden-lines'].trim() || null,
      iconicElements: draft['iconic-elements'].trim() || null,
    },
  };
}

function WorldAttributeSchemaEditor({
  value,
  onChange,
}: {
  value: CustomWorldProfile['attributeSchema'];
  onChange: (value: CustomWorldProfile['attributeSchema']) => void;
}) {
  const updateSlot = (
    slotId: string,
    patch: Partial<CustomWorldProfile['attributeSchema']['slots'][number]>,
  ) => {
    onChange({
      ...value,
      slots: value.slots.map((slot) =>
        slot.slotId === slotId ? { ...slot, ...patch } : slot,
      ),
    });
  };

  return (
    <SectionPanel title="角色维度">
      <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
        {value.slots.map((slot) => (
          <div
            key={slot.slotId}
            className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
          >
            <Field label="维度名称">
              <TextInput
                value={slot.name}
                onChange={(name) => updateSlot(slot.slotId, { name })}
              />
            </Field>
          </div>
        ))}
      </div>
    </SectionPanel>
  );
}

export function WorldFoundationEditor({
  profile,
  onSave,
  onClose,
}: {
  profile: CustomWorldProfile;
  onSave: (profile: CustomWorldProfile) => void;
  onClose: () => void;
}) {
  const initialDraft = useMemo(() => buildFoundationDraft(profile), [profile]);
  const [draft, setDraft] = useDraft(initialDraft);
  const [attributeSchemaDraft, setAttributeSchemaDraft] = useDraft(
    profile.attributeSchema,
  );

  return (
    <ModalShell
      title="编辑基本设定"
      onClose={onClose}
      panelClassName="sm:max-w-5xl xl:max-w-7xl 2xl:max-w-[92rem]"
    >
      <div className="space-y-4">
        {FOUNDATION_EDITOR_FIELDS.map((field) => (
          <Field key={field.id} label={field.label}>
            <div className="space-y-3">
              <TextArea
                value={draft[field.id]}
                onChange={(value) =>
                  setDraft((current) => ({
                    ...current,
                    [field.id]: value,
                  }))
                }
                rows={field.rows}
              />
              {draft[field.id].trim() ? (
                <div className="flex flex-wrap gap-2">
                  {parseFoundationTagText(draft[field.id]).map((tag, index) => (
                    <PlatformPillBadge
                      key={`${field.id}-${index}-${tag}`}
                      tone="darkSoft"
                      size="sm"
                      className="leading-5"
                    >
                      {tag}
                    </PlatformPillBadge>
                  ))}
                </div>
              ) : null}
            </div>
          </Field>
        ))}
        <WorldAttributeSchemaEditor
          value={attributeSchemaDraft}
          onChange={setAttributeSchemaDraft}
        />
        <SaveBar
          onClose={onClose}
          onSave={() => {
            const nextProfile = applyFoundationDraftToProfile(profile, draft);
            onSave({
              ...nextProfile,
              attributeSchema: attributeSchemaDraft,
              ownedSettingLayers: nextProfile.ownedSettingLayers
                ? {
                    ...nextProfile.ownedSettingLayers,
                    ruleProfile: {
                      ...nextProfile.ownedSettingLayers.ruleProfile,
                      attributeSchema: attributeSchemaDraft,
                    },
                  }
                : nextProfile.ownedSettingLayers,
            });
            onClose();
          }}
        />
      </div>
    </ModalShell>
  );
}

export function CampSceneEditor({
  profile,
  onSaveProfile,
  onClose,
}: {
  profile: CustomWorldProfile;
  onSaveProfile: (profile: CustomWorldProfile) => void;
  onClose: () => void;
}) {
  return (
    <LandmarkEditor
      profile={profile}
      landmark={resolveCustomWorldCampScene(profile)}
      mode="edit"
      sceneKind="camp"
      onSaveProfile={onSaveProfile}
      onClose={onClose}
    />
  );
}

export function PlayableNpcEditor({
  profile,
  npc,
  mode,
  onSave,
  onClose,
}: {
  profile: CustomWorldProfile;
  npc: CustomWorldPlayableNpc;
  mode: 'create' | 'edit';
  onSave: (npc: CustomWorldPlayableNpc) => void;
  onClose: () => void;
}) {
  const [draft, setDraft] = useDraft(npc);
  const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
  const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
  const [editorNotice, setEditorNotice] = useState<string | null>(null);
  const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
  const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
  const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
  const previewImageSrc = draft.imageSrc?.trim() ?? '';
  const roleOptions = useMemo(
    () =>
      [...profile.playableNpcs, ...profile.storyNpcs]
        .filter((role) => role.id !== draft.id)
        .map((role) => ({
          value: role.id,
          label: [role.name, role.title || role.role]
            .filter(Boolean)
            .join(' / '),
        })),
    [draft.id, profile.playableNpcs, profile.storyNpcs],
  );
  const roleRelations =
    draft.relations ??
    draft.relationshipHooks.map((summary, index) => ({
      id: createEntryId('relation', draft.id, index),
      targetRoleId: '',
      summary,
    }));

  const handleRequestClose = () => {
    if (!hasUnsavedChanges) {
      onClose();
      return;
    }
    setIsCloseConfirmOpen(true);
  };
  return (
    <>
      <ModalShell
        title={mode === 'create' ? '新增可扮演角色' : `编辑角色：${npc.name}`}
        onClose={handleRequestClose}
        panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
        disableClose={
          isAiAssetStudioOpen || isCloseConfirmOpen || editorNotice !== null
        }
      >
        <div className="space-y-4">
          {previewImageSrc ? (
            <div className="rounded-2xl border border-white/8 bg-black/20 p-4">
              <div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
                形象预览
              </div>
              <div className="mt-3 grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
                <PlatformMediaFrame
                  src={previewImageSrc}
                  alt={draft.name || '角色形象'}
                  fallbackLabel="角色形象"
                  aspect="square"
                  surface="editorDark"
                  className="w-full rounded-2xl"
                  imageClassName="h-full w-full object-contain object-top"
                />
                <div className="min-w-0">
                  <div className="text-base font-semibold text-white">
                    {draft.name || '未命名角色'}
                  </div>
                  <div className="mt-1 text-sm text-zinc-400">
                    {draft.title || draft.role}
                  </div>
                  <RoleAssetAppliedBadges
                    generatedVisualAssetId={draft.generatedVisualAssetId}
                    generatedAnimationSetId={draft.generatedAnimationSetId}
                    className="mt-3"
                  />
                  <div className="mt-3">
                    <ActionButton
                      label="AI生成"
                      onClick={() => setIsAiAssetStudioOpen(true)}
                      tone="sky"
                    />
                  </div>
                </div>
              </div>
            </div>
          ) : (
            <div className="rounded-2xl border border-white/8 bg-black/20 p-4">
              <div className="flex flex-wrap items-center justify-between gap-3">
                <div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
                  角色形象
                </div>
                <ActionButton
                  label="AI生成"
                  onClick={() => setIsAiAssetStudioOpen(true)}
                  tone="sky"
                />
              </div>
            </div>
          )}
          <Field label="名称">
            <TextInput
              value={draft.name}
              onChange={(value) =>
                setDraft((current) => ({ ...current, name: value }))
              }
            />
          </Field>
          <Field label="头衔 / 世界身份">
            <TextInput
              value={draft.title || draft.role}
              onChange={(value) =>
                setDraft((current) => ({
                  ...current,
                  title: value,
                  role: value,
                }))
              }
            />
          </Field>
          <Field label="简介">
            <TextArea
              value={draft.description}
              onChange={(value) =>
                setDraft((current) => ({ ...current, description: value }))
              }
              rows={3}
            />
          </Field>
          <Field label="背景">
            <TextArea
              value={draft.backstory}
              onChange={(value) =>
                setDraft((current) => ({ ...current, backstory: value }))
              }
              rows={5}
            />
          </Field>
          <Field label="性格">
            <TextArea
              value={draft.personality}
              onChange={(value) =>
                setDraft((current) => ({ ...current, personality: value }))
              }
              rows={3}
            />
          </Field>
          <Field label="当前动机">
            <TextArea
              value={draft.motivation}
              onChange={(value) =>
                setDraft((current) => ({ ...current, motivation: value }))
              }
              rows={3}
            />
          </Field>
          <Field label="初始好感">
            <TextInput
              type="number"
              value={draft.initialAffinity}
              onChange={(value) =>
                setDraft((current) => ({
                  ...current,
                  initialAffinity: clampInitialAffinity(
                    value,
                    current.initialAffinity,
                  ),
                }))
              }
            />
          </Field>
          <Field label="标签">
            <TextArea
              value={commaText(draft.tags)}
              onChange={(value) =>
                setDraft((current) => ({
                  ...current,
                  tags: parseCommaText(value),
                }))
              }
              rows={2}
            />
          </Field>
          <BackstoryRevealEditor
            value={draft.backstoryReveal}
            onNotice={setEditorNotice}
            onChange={(backstoryReveal) =>
              setDraft((current) => ({
                ...current,
                backstoryReveal,
              }))
            }
          />
          <RoleRelationsEditor
            value={roleRelations}
            onChange={(relations) =>
              setDraft((current) => ({
                ...current,
                relations,
                relationshipHooks:
                  deriveRelationshipHooksFromRelations(relations),
              }))
            }
            roleOptions={roleOptions}
            labelSeed={draft.name || draft.id}
          />
          <SkillListEditor
            role={draft}
            value={draft.skills}
            onChange={(skills) =>
              setDraft((current) => ({
                ...current,
                skills,
              }))
            }
            labelSeed={draft.name || draft.id}
          />
          <InitialItemsEditor
            value={draft.initialItems}
            onChange={(initialItems) =>
              setDraft((current) => ({
                ...current,
                initialItems,
              }))
            }
            labelSeed={draft.name || draft.id}
          />
          <SaveBar
            onClose={handleRequestClose}
            onSave={() => {
              onSave(draft);
              onClose();
            }}
            showClose={false}
          />
          {isAiAssetStudioOpen ? (
            <RpgCreationRoleAssetStudioModal
              role={draft}
              roleKind="playable"
              cacheScopeId={profile.id}
              onApply={(nextRole) =>
                setDraft((current) => ({
                  ...current,
                  ...nextRole,
                }))
              }
              onClose={() => setIsAiAssetStudioOpen(false)}
            />
          ) : null}
        </div>
      </ModalShell>
      {editorNotice ? (
        <EditorNoticeDialog
          message={editorNotice}
          onClose={() => setEditorNotice(null)}
        />
      ) : null}
      {isCloseConfirmOpen ? (
        <CloseConfirmDialog
          message="当前修改尚未保存，确认关闭吗？"
          onCancel={() => setIsCloseConfirmOpen(false)}
          onConfirm={() => {
            setIsCloseConfirmOpen(false);
            onClose();
          }}
        />
      ) : null}
    </>
  );
}

export function StoryNpcEditor({
  profile,
  npc,
  mode,
  onSave,
  onClose,
}: {
  profile: CustomWorldProfile;
  npc: CustomWorldNpc;
  mode: 'create' | 'edit';
  onSave: (npc: CustomWorldNpc) => void;
  onClose: () => void;
}) {
  const [draft, setDraft] = useDraft(npc);
  const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
  const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
  const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
  const [editorNotice, setEditorNotice] = useState<string | null>(null);
  const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
  const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
  const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
  const roleOptions = useMemo(
    () =>
      [...profile.playableNpcs, ...profile.storyNpcs]
        .filter((role) => role.id !== draft.id)
        .map((role) => ({
          value: role.id,
          label: [role.name, role.title || role.role]
            .filter(Boolean)
            .join(' / '),
        })),
    [draft.id, profile.playableNpcs, profile.storyNpcs],
  );
  const roleRelations =
    draft.relations ??
    draft.relationshipHooks.map((summary, index) => ({
      id: createEntryId('relation', draft.id, index),
      targetRoleId: '',
      summary,
    }));

  const handleRequestClose = () => {
    if (!hasUnsavedChanges) {
      onClose();
      return;
    }
    setIsCloseConfirmOpen(true);
  };
  return (
    <>
      <ModalShell
        title={mode === 'create' ? '新增场景角色' : `编辑场景角色：${npc.name}`}
        onClose={handleRequestClose}
        panelClassName="sm:max-w-4xl xl:max-w-6xl 2xl:max-w-7xl"
        disableClose={
          isHistoryPickerOpen ||
          isAiAssetStudioOpen ||
          isCloseConfirmOpen ||
          editorNotice !== null
        }
      >
        <div className="space-y-4">
          <div className="rounded-3xl border border-white/10 bg-black/20 p-4">
            <div className="text-[11px] font-bold tracking-[0.18em] text-zinc-300">
              形象预览
            </div>
            <div className="mt-3 grid gap-4 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
              <div className="flex justify-center">
                <CustomWorldNpcPortrait
                  npc={draft}
                  visual={draft.visual}
                  className="aspect-square w-full max-w-[9.5rem]"
                  scale={2.05}
                  preferImageSrc
                />
              </div>
              <div className="min-w-0 space-y-3">
                <div className="flex flex-wrap gap-3">
                  <ActionButton
                    label="使用历史素材"
                    onClick={() => setIsHistoryPickerOpen(true)}
                    tone="sky"
                  />
                  <ActionButton
                    label="AI生成"
                    onClick={() => setIsAiAssetStudioOpen(true)}
                    tone="sky"
                  />
                </div>
                <RoleAssetAppliedBadges
                  generatedVisualAssetId={draft.generatedVisualAssetId}
                  generatedAnimationSetId={draft.generatedAnimationSetId}
                />
              </div>
            </div>
          </div>
          <Field label="名称">
            <TextInput
              value={draft.name}
              onChange={(value) =>
                setDraft((current) => ({ ...current, name: value }))
              }
            />
          </Field>
          <Field label="头衔 / 世界身份">
            <TextInput
              value={draft.title || draft.role}
              onChange={(value) =>
                setDraft((current) => ({
                  ...current,
                  title: value,
                  role: value,
                }))
              }
            />
          </Field>
          <Field label="描述">
            <TextArea
              value={draft.description}
              onChange={(value) =>
                setDraft((current) => ({ ...current, description: value }))
              }
              rows={4}
            />
          </Field>
          <Field label="背景">
            <TextArea
              value={draft.backstory}
              onChange={(value) =>
                setDraft((current) => ({ ...current, backstory: value }))
              }
              rows={4}
            />
          </Field>
          <Field label="性格">
            <TextArea
              value={draft.personality}
              onChange={(value) =>
                setDraft((current) => ({ ...current, personality: value }))
              }
              rows={3}
            />
          </Field>
          <Field label="动机">
            <TextArea
              value={draft.motivation}
              onChange={(value) =>
                setDraft((current) => ({ ...current, motivation: value }))
              }
              rows={4}
            />
          </Field>
          <Field label="初始好感">
            <TextInput
              type="number"
              value={draft.initialAffinity}
              onChange={(value) =>
                setDraft((current) => ({
                  ...current,
                  initialAffinity: clampInitialAffinity(
                    value,
                    current.initialAffinity,
                  ),
                }))
              }
            />
          </Field>
          <Field label="标签">
            <TextArea
              value={commaText(draft.tags)}
              onChange={(value) =>
                setDraft((current) => ({
                  ...current,
                  tags: parseCommaText(value),
                }))
              }
              rows={2}
            />
          </Field>
          <BackstoryRevealEditor
            value={draft.backstoryReveal}
            onNotice={setEditorNotice}
            onChange={(backstoryReveal) =>
              setDraft((current) => ({
                ...current,
                backstoryReveal,
              }))
            }
          />
          <RoleRelationsEditor
            value={roleRelations}
            onChange={(relations) =>
              setDraft((current) => ({
                ...current,
                relations,
                relationshipHooks:
                  deriveRelationshipHooksFromRelations(relations),
              }))
            }
            roleOptions={roleOptions}
            labelSeed={draft.name || draft.id}
          />
          <SkillListEditor
            role={draft}
            value={draft.skills}
            onChange={(skills) =>
              setDraft((current) => ({
                ...current,
                skills,
              }))
            }
            labelSeed={draft.name || draft.id}
          />
          <InitialItemsEditor
            value={draft.initialItems}
            onChange={(initialItems) =>
              setDraft((current) => ({
                ...current,
                initialItems,
              }))
            }
            labelSeed={draft.name || draft.id}
          />
          <SaveBar
            onClose={handleRequestClose}
            onSave={() => {
              onSave(draft);
              onClose();
            }}
            showClose={false}
          />
          {isHistoryPickerOpen ? (
            <HistoryAssetPickerModal
              title="使用历史素材"
              kind="character_visual"
              tone="square"
              onSelect={(asset) => {
                setDraft((current) => ({
                  ...current,
                  imageSrc: asset.imageSrc,
                  generatedVisualAssetId: asset.assetObjectId,
                  generatedAnimationSetId: undefined,
                  animationMap: undefined,
                }));
                setIsHistoryPickerOpen(false);
              }}
              onClose={() => setIsHistoryPickerOpen(false)}
            />
          ) : null}
          {isAiAssetStudioOpen ? (
            <RpgCreationRoleAssetStudioModal
              role={draft}
              roleKind="story"
              cacheScopeId={profile.id}
              onApply={(nextRole) =>
                setDraft((current) => ({
                  ...current,
                  ...nextRole,
                }))
              }
              onClose={() => setIsAiAssetStudioOpen(false)}
            />
          ) : null}
        </div>
      </ModalShell>
      {editorNotice ? (
        <EditorNoticeDialog
          message={editorNotice}
          onClose={() => setEditorNotice(null)}
        />
      ) : null}
      {isCloseConfirmOpen ? (
        <CloseConfirmDialog
          message="当前修改尚未保存，确认关闭吗？"
          onCancel={() => setIsCloseConfirmOpen(false)}
          onConfirm={() => {
            setIsCloseConfirmOpen(false);
            onClose();
          }}
        />
      ) : null}
    </>
  );
}

export function LandmarkEditor({
  profile,
  landmark,
  mode,
  sceneKind = 'landmark',
  onSaveProfile,
  onClose,
}: {
  profile: CustomWorldProfile;
  landmark: CustomWorldLandmark;
  mode: 'create' | 'edit';
  sceneKind?: 'camp' | 'landmark';
  onSaveProfile: (profile: CustomWorldProfile) => void;
  onClose: () => void;
}) {
  const isOpeningScene = sceneKind === 'camp';
  const [draft, setDraft] = useDraft(landmark);
  const [draftStoryNpcs, setDraftStoryNpcs] = useDraft(profile.storyNpcs);
  const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
  const [validationNotice, setValidationNotice] = useState<string | null>(null);
  const [isWorldMapOpen, setIsWorldMapOpen] = useState(false);
  const [activeConnectionDirection, setActiveConnectionDirection] =
    useState<CardinalConnectionDirection | null>(null);
  const [activeSceneActSlotPickerState, setActiveSceneActSlotPickerState] =
    useState<{
      actIndex: number;
      slotIndex: number;
    } | null>(null);
  const [activeSceneActBackgroundIndex, setActiveSceneActBackgroundIndex] =
    useState<number | null>(null);
  const [activeSceneActPreviewIndex, setActiveSceneActPreviewIndex] = useState<
    number | null
  >(null);
  const [npcEditorState, setNpcEditorState] = useState<{
    mode: 'create' | 'edit';
    npc: CustomWorldNpc;
  } | null>(null);
  const resolvedInitialLandmarkImageSrc = useMemo(() => {
    if (isOpeningScene) {
      return resolveCustomWorldCampSceneImage({
        ...profile,
        camp: landmark,
      });
    }
    const landmarkIndex = profile.landmarks.findIndex(
      (entry) => entry.id === landmark.id,
    );

    return resolveCustomWorldLandmarkImage(
      profile,
      landmark,
      landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
      profile.landmarks
        .filter((entry) => entry.id !== landmark.id)
        .map((entry) => entry.imageSrc)
        .filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
    );
  }, [isOpeningScene, landmark, profile]);
  const initialSceneChapterDraft = useMemo(
    () =>
      resolveSceneChapterBlueprintDraft({
        profile,
        landmark,
        fallbackImageSrc: resolvedInitialLandmarkImageSrc,
      }),
    [landmark, profile, resolvedInitialLandmarkImageSrc],
  );
  const [sceneChapterDraft, setSceneChapterDraft] = useDraft(
    initialSceneChapterDraft,
  );
  const resolvedDraftImageSrc = useMemo(() => {
    if (isOpeningScene) {
      return resolveCustomWorldCampSceneImage({
        ...profile,
        camp: draft,
      });
    }
    const landmarkIndex = profile.landmarks.findIndex(
      (entry) => entry.id === draft.id,
    );

    return resolveCustomWorldLandmarkImage(
      profile,
      draft,
      landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
      profile.landmarks
        .filter((entry) => entry.id !== draft.id)
        .map((entry) => entry.imageSrc)
        .filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
    );
  }, [draft, isOpeningScene, profile]);
  const previewPlayableCharacter = useMemo(
    () =>
      buildCustomWorldPlayableCharacters({
        ...profile,
        storyNpcs: draftStoryNpcs,
      })[0] ??
      ROLE_TEMPLATE_CHARACTERS[0] ??
      null,
    [draftStoryNpcs, profile],
  );
  const renderedSceneChapterDraft = useMemo(
    () =>
      sanitizeSceneChapterBlueprint({
        chapter: sceneChapterDraft,
        landmark: {
          ...draft,
          sceneNpcIds: dedupeTextValues(draft.sceneNpcIds),
        },
        fallbackImageSrc: resolvedDraftImageSrc,
      }),
    [draft, resolvedDraftImageSrc, sceneChapterDraft],
  );
  const derivedSceneNpcIds = useMemo(
    () => collectSceneChapterEncounterNpcIds(renderedSceneChapterDraft),
    [renderedSceneChapterDraft],
  );
  const compatibilitySceneNpcIds = useMemo(
    () =>
      resolveSceneCompatibilityNpcIds({
        chapter: renderedSceneChapterDraft,
        currentNpcIds: draft.sceneNpcIds,
      }),
    [draft.sceneNpcIds, renderedSceneChapterDraft],
  );
  const sceneNpcOptions = useMemo(
    () =>
      buildSceneActSelectableNpcs({
        profile,
        storyNpcs: draftStoryNpcs,
        preferredNpcIds: compatibilitySceneNpcIds,
      }),
    [compatibilitySceneNpcIds, draftStoryNpcs, profile],
  );
  const selectableNpcById = useMemo(
    () => new Map(sceneNpcOptions.map((npc) => [npc.id, npc])),
    [sceneNpcOptions],
  );
  const compatibilityImageSrc = useMemo(
    () =>
      resolveSceneCompatibilityImageSrc({
        chapter: renderedSceneChapterDraft,
        currentImageSrc: draft.imageSrc,
        resolvedImageSrc: resolvedDraftImageSrc,
      }),
    [draft.imageSrc, renderedSceneChapterDraft, resolvedDraftImageSrc],
  );
  const activeSceneActSlotDraft = activeSceneActSlotPickerState
    ? (renderedSceneChapterDraft.acts[activeSceneActSlotPickerState.actIndex] ??
      null)
    : null;
  const activeSceneActBackgroundDraft =
    activeSceneActBackgroundIndex !== null
      ? (renderedSceneChapterDraft.acts[activeSceneActBackgroundIndex] ?? null)
      : null;
  const activeSceneActPreviewDraft =
    activeSceneActPreviewIndex !== null
      ? (renderedSceneChapterDraft.acts[activeSceneActPreviewIndex] ?? null)
      : null;
  const availableTargetLandmarks = useMemo(
    () => profile.landmarks.filter((entry) => entry.id !== draft.id),
    [draft.id, profile.landmarks],
  );
  const editableProfile = useMemo(() => {
    const nextLandmarks = isOpeningScene
      ? profile.landmarks
      : mode === 'create'
        ? [...profile.landmarks, draft]
        : profile.landmarks.map((entry) =>
            entry.id === draft.id ? draft : entry,
          );

    return {
      ...profile,
      camp: isOpeningScene ? draft : profile.camp,
      storyNpcs: draftStoryNpcs,
      landmarks: nextLandmarks,
    };
  }, [draft, draftStoryNpcs, isOpeningScene, mode, profile]);
  const worldMapLandmarks = useMemo(() => {
    const campScene = resolveCustomWorldCampScene({
      ...editableProfile,
      camp: isOpeningScene ? draft : editableProfile.camp,
    });
    const normalizedCampScene: CustomWorldLandmark = {
      ...campScene,
      connections: buildDirectionalConnections(
        campScene.connections,
        editableProfile.landmarks,
      ),
    };
    const normalizedLandmarks = editableProfile.landmarks.map((entry) => ({
      ...entry,
      connections: buildDirectionalConnections(entry.connections, [
        normalizedCampScene,
        ...editableProfile.landmarks.filter(
          (candidate) => candidate.id !== entry.id,
        ),
      ]),
    }));

    // 中文注释：地图预览必须使用当前草稿对象，避免未保存的连接关系在弹窗里缺席。
    return [normalizedCampScene, ...normalizedLandmarks];
  }, [draft, editableProfile, isOpeningScene]);
  const previewProfile = useMemo(
    () => ({
      ...editableProfile,
      sceneChapterBlueprints: upsertSceneChapterBlueprint(
        editableProfile.sceneChapterBlueprints,
        renderedSceneChapterDraft,
      ),
    }),
    [editableProfile, renderedSceneChapterDraft],
  );
  const directionalConnections = useMemo(
    () =>
      buildDirectionalConnections(draft.connections, availableTargetLandmarks),
    [availableTargetLandmarks, draft.connections],
  );
  const directionTargetLabels = useMemo(
    () =>
      Object.fromEntries(
        directionalConnections.map((connection) => [
          connection.relativePosition,
          availableTargetLandmarks.find(
            (entry) => entry.id === connection.targetLandmarkId,
          )?.name || '',
        ]),
      ) as Partial<Record<CardinalConnectionDirection, string>>,
    [availableTargetLandmarks, directionalConnections],
  );
  const activeDirectionConnection = useMemo(
    () =>
      activeConnectionDirection
        ? directionalConnections.find(
            (connection) =>
              connection.relativePosition === activeConnectionDirection,
          ) || null
        : null,
    [activeConnectionDirection, directionalConnections],
  );
  const initialDirectionalConnections = useMemo(
    () =>
      buildDirectionalConnections(
        landmark.connections,
        availableTargetLandmarks,
      ),
    [availableTargetLandmarks, landmark.connections],
  );
  const initialLandmarkSnapshot = useMemo(
    () =>
      JSON.stringify({
        ...landmark,
        sceneNpcIds: [...new Set(landmark.sceneNpcIds)],
        connections: initialDirectionalConnections,
      }),
    [initialDirectionalConnections, landmark],
  );
  const currentLandmarkSnapshot = useMemo(
    () =>
      JSON.stringify({
        ...draft,
        sceneNpcIds: [...new Set(draft.sceneNpcIds)],
        connections: directionalConnections,
      }),
    [directionalConnections, draft],
  );
  const initialStoryNpcSnapshot = useMemo(
    () => JSON.stringify(profile.storyNpcs),
    [profile.storyNpcs],
  );
  const currentStoryNpcSnapshot = useMemo(
    () => JSON.stringify(draftStoryNpcs),
    [draftStoryNpcs],
  );
  const initialSceneChapterSnapshot = useMemo(
    () => JSON.stringify(initialSceneChapterDraft),
    [initialSceneChapterDraft],
  );
  const currentSceneChapterSnapshot = useMemo(
    () => JSON.stringify(renderedSceneChapterDraft),
    [renderedSceneChapterDraft],
  );
  const hasUnsavedChanges =
    initialLandmarkSnapshot !== currentLandmarkSnapshot ||
    initialStoryNpcSnapshot !== currentStoryNpcSnapshot ||
    initialSceneChapterSnapshot !== currentSceneChapterSnapshot;

  const handleRequestClose = () => {
    if (!hasUnsavedChanges) {
      onClose();
      return;
    }
    setIsCloseConfirmOpen(true);
  };
  const showValidationNotice = (message: string) => {
    setValidationNotice(message);
  };

  const updateSceneChapterDraft = (
    updater: (chapter: SceneChapterBlueprint) => SceneChapterBlueprint,
  ) => {
    setSceneChapterDraft((current) =>
      sanitizeSceneChapterBlueprint({
        chapter: updater(
          sanitizeSceneChapterBlueprint({
            chapter: current,
            landmark: {
              ...draft,
              sceneNpcIds: dedupeTextValues(draft.sceneNpcIds),
            },
            fallbackImageSrc: resolvedDraftImageSrc,
          }),
        ),
        landmark: {
          ...draft,
          sceneNpcIds: dedupeTextValues(draft.sceneNpcIds),
        },
        fallbackImageSrc: resolvedDraftImageSrc,
      }),
    );
  };

  const updateSceneActField = (
    index: number,
    updater: (act: SceneActBlueprint) => SceneActBlueprint,
  ) => {
    updateSceneChapterDraft((current) => ({
      ...current,
      acts: current.acts.map((act, actIndex) =>
        actIndex === index ? updater(act) : act,
      ),
    }));
  };

  const addSceneAct = () => {
    if (renderedSceneChapterDraft.acts.length >= MAX_SCENE_ACT_COUNT) {
      showValidationNotice(`每个场景最多只能配置 ${MAX_SCENE_ACT_COUNT} 幕。`);
      return;
    }

    updateSceneChapterDraft((current) => {
      const nextActCount = current.acts.length + 1;
      return {
        ...current,
        acts: [
          ...current.acts,
          buildDefaultSceneActBlueprint({
            sceneId: draft.id,
            sceneName: draft.name,
            sceneSummary: draft.description,
            encounterNpcIds: derivedSceneNpcIds,
            backgroundImageSrc: compatibilityImageSrc || resolvedDraftImageSrc,
            linkedThreadIds: current.linkedThreadIds,
            index: current.acts.length,
            actCount: nextActCount,
          }),
        ],
      };
    });
  };

  const removeSceneAct = (index: number) => {
    if (renderedSceneChapterDraft.acts.length <= MIN_SCENE_ACT_COUNT) {
      showValidationNotice(`每个场景至少需要保留 ${MIN_SCENE_ACT_COUNT} 幕。`);
      return;
    }

    updateSceneChapterDraft((current) => ({
      ...current,
      acts: current.acts.filter((_act, actIndex) => actIndex !== index),
    }));
  };

  const moveSceneAct = (index: number, delta: number) => {
    updateSceneChapterDraft((current) => ({
      ...current,
      acts: moveArrayItem(current.acts, index, index + delta),
    }));
  };

  const updateSceneActBackground = (
    actIndex: number,
    imageSrc?: string | null,
    assetId?: string | null,
  ) => {
    const resolvedImageSrc = imageSrc?.trim() || '';
    const normalizedAssetId = assetId?.trim();
    updateSceneChapterDraft((current) => ({
      ...current,
      acts: current.acts.map((act, currentActIndex) =>
        currentActIndex === actIndex
          ? {
              ...act,
              backgroundImageSrc: resolvedImageSrc || undefined,
              backgroundAssetId:
                normalizedAssetId !== undefined
                  ? normalizedAssetId || undefined
                  : resolvedImageSrc
                    ? act.backgroundAssetId
                    : undefined,
            }
          : act,
      ),
    }));
  };

  const updateDirectionalConnection = (
    direction: CardinalConnectionDirection,
    targetLandmarkId: string,
  ) => {
    const targetName =
      availableTargetLandmarks.find((entry) => entry.id === targetLandmarkId)
        ?.name || '';

    setDraft((current) => {
      const nextConnections = buildDirectionalConnections(
        current.connections,
        availableTargetLandmarks,
      ).filter((connection) => connection.relativePosition !== direction);

      return {
        ...current,
        connections: [
          ...nextConnections,
          {
            targetLandmarkId,
            relativePosition: direction,
            summary: buildConnectionSummary(direction, targetName),
          },
        ],
      };
    });
  };

  const removeDirectionalConnection = (
    direction: CardinalConnectionDirection,
  ) => {
    setDraft((current) => ({
      ...current,
      connections: buildDirectionalConnections(
        current.connections,
        availableTargetLandmarks,
      ).filter((connection) => connection.relativePosition !== direction),
    }));
  };

  const handleOpenDirectionPicker = (
    direction: CardinalConnectionDirection,
  ) => {
    if (availableTargetLandmarks.length === 0) {
      showValidationNotice('请先保留至少一个其他场景，才能配置连接关系。');
      return;
    }
    setActiveConnectionDirection(direction);
  };

  const saveLandmarkProfile = () => {
    const sanitizedDraft = {
      ...draft,
      imageSrc: compatibilityImageSrc,
      sceneNpcIds: derivedSceneNpcIds,
      connections: buildDirectionalConnections(
        draft.connections,
        availableTargetLandmarks,
      ).map((connection) => ({
        ...connection,
        summary:
          connection.summary ||
          buildConnectionSummary(
            connection.relativePosition as CardinalConnectionDirection,
            availableTargetLandmarks.find(
              (entry) => entry.id === connection.targetLandmarkId,
            )?.name,
          ),
      })),
    };

    if (derivedSceneNpcIds.length < 1) {
      showValidationNotice('请至少为一幕配置主角色。');
      return;
    }

    const nextLandmarks = isOpeningScene
      ? profile.landmarks
      : mode === 'create'
        ? [...profile.landmarks, sanitizedDraft]
        : profile.landmarks.map((entry) =>
            entry.id === sanitizedDraft.id ? sanitizedDraft : entry,
          );
    const syncedLandmarks = syncLandmarksWithStoryNpcs(
      nextLandmarks,
      draftStoryNpcs,
    );
    const syncedSavedLandmark =
      syncedLandmarks.find((entry) => entry.id === sanitizedDraft.id) ??
      sanitizedDraft;
    const nextProfileBase = {
      ...profile,
      camp: isOpeningScene ? sanitizedDraft : profile.camp,
      storyNpcs: draftStoryNpcs,
      landmarks: syncedLandmarks,
    };
    const nextProfileWithCompatibilityFields = isOpeningScene
      ? {
          ...nextProfileBase,
          camp: syncedSavedLandmark,
        }
      : nextProfileBase;
    const savedLandmark = isOpeningScene
      ? syncedSavedLandmark
      : syncedSavedLandmark;
    const savedLandmarkIndex = syncedLandmarks.findIndex(
      (entry) => entry.id === savedLandmark.id,
    );
    const nextSceneChapterBlueprint = sanitizeSceneChapterBlueprint({
      chapter: renderedSceneChapterDraft,
      landmark: savedLandmark,
      fallbackImageSrc: isOpeningScene
        ? resolveCustomWorldCampSceneImage({
            ...nextProfileWithCompatibilityFields,
            camp: savedLandmark,
          })
        : resolveCustomWorldLandmarkImage(
            nextProfileWithCompatibilityFields,
            savedLandmark,
            savedLandmarkIndex >= 0
              ? savedLandmarkIndex
              : syncedLandmarks.length,
            syncedLandmarks
              .filter((entry) => entry.id !== savedLandmark.id)
              .map((entry) => entry.imageSrc)
              .filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
          ),
    });

    onSaveProfile({
      ...nextProfileWithCompatibilityFields,
      sceneChapterBlueprints: upsertSceneChapterBlueprint(
        profile.sceneChapterBlueprints,
        nextSceneChapterBlueprint,
      ),
    });
    onClose();
  };

  return (
    <>
      <ModalShell
        title={
          mode === 'create'
            ? '新增场景'
            : `编辑场景：${landmark.name || (isOpeningScene ? '开局场景' : '未命名场景')}`
        }
        onClose={handleRequestClose}
        panelClassName="sm:max-w-5xl xl:max-w-7xl 2xl:max-w-[96rem]"
      >
        <div className="space-y-4">
          <Field label="名称">
            <TextInput
              value={draft.name}
              onChange={(value) =>
                setDraft((current) => ({ ...current, name: value }))
              }
            />
          </Field>
          <Field label="描述">
            <TextArea
              value={draft.description}
              onChange={(value) =>
                setDraft((current) => ({ ...current, description: value }))
              }
              rows={5}
            />
          </Field>
          <Field label="场景任务">
            <TextArea
              value={renderedSceneChapterDraft.sceneTaskDescription}
              onChange={(value) =>
                updateSceneChapterDraft((current) => ({
                  ...current,
                  sceneTaskDescription: value,
                }))
              }
              rows={3}
            />
          </Field>
          <SectionPanel
            title="多幕配置"
            actions={
              <ActionButton
                label={
                  renderedSceneChapterDraft.acts.length >= MAX_SCENE_ACT_COUNT
                    ? `已满 ${MAX_SCENE_ACT_COUNT} 幕`
                    : '新增一幕'
                }
                onClick={addSceneAct}
                tone="sky"
                disabled={
                  renderedSceneChapterDraft.acts.length >= MAX_SCENE_ACT_COUNT
                }
              />
            }
          >
            {renderedSceneChapterDraft.acts.map((act, index) => {
              const actLabel =
                act.title.trim() || buildDefaultSceneActTitle(index);
              const encounterSlotIds = buildSceneActEncounterSlotIds(
                act.encounterNpcIds,
              );
              const encounterSlotNpcs = encounterSlotIds.map((npcId) =>
                npcId ? (selectableNpcById.get(npcId) ?? null) : null,
              );

              return (
                <div
                  key={`${act.id}-${index}`}
                  data-testid="scene-act-card"
                  className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
                >
                  <div className="flex flex-wrap items-start justify-between gap-3">
                    <div>
                      <div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
                        第{index + 1}幕
                      </div>
                      <div className="mt-1 text-sm font-semibold text-white">
                        {actLabel}
                      </div>
                    </div>
                    <div className="flex flex-wrap gap-2">
                      <ActionButton
                        label="上移"
                        onClick={() => moveSceneAct(index, -1)}
                        disabled={index === 0}
                      />
                      <ActionButton
                        label="下移"
                        onClick={() => moveSceneAct(index, 1)}
                        disabled={
                          index >= renderedSceneChapterDraft.acts.length - 1
                        }
                      />
                      <ActionButton
                        label="删除"
                        onClick={() => removeSceneAct(index)}
                        disabled={
                          renderedSceneChapterDraft.acts.length <=
                          MIN_SCENE_ACT_COUNT
                        }
                      />
                    </div>
                  </div>

                  <div className="mt-4 space-y-3">
                    <div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
                      <SceneActStagePreview
                        actLabel={actLabel}
                        imageSrc={
                          act.backgroundImageSrc?.trim() ||
                          compatibilityImageSrc
                        }
                        fallbackImageSrc={resolvedDraftImageSrc}
                        previewCharacter={previewPlayableCharacter}
                        slotNpcs={encounterSlotNpcs}
                        onSlotClick={(slotIndex) => {
                          if (sceneNpcOptions.length === 0) {
                            showValidationNotice(
                              '请先在世界档案里创建角色，再配置这一幕的角色槽位。',
                            );
                            return;
                          }
                          if (
                            slotIndex > 0 &&
                            !encounterSlotNpcs[slotIndex - 1]
                          ) {
                            showValidationNotice('请先配置前一个角色槽位。');
                            return;
                          }
                          setActiveSceneActSlotPickerState({
                            actIndex: index,
                            slotIndex,
                          });
                        }}
                      />
                      <div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                        <div className="min-w-0">
                          <div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
                            幕背景
                          </div>
                          <div className="mt-1 text-sm text-zinc-200">
                            已读取场景图
                          </div>
                        </div>
                        <div className="flex flex-wrap gap-2">
                          <ActionButton
                            label="配置背景"
                            onClick={() =>
                              setActiveSceneActBackgroundIndex(index)
                            }
                            tone="sky"
                          />
                          <ActionButton
                            label="幕预览"
                            onClick={() => {
                              if (!encounterSlotNpcs[0]) {
                                showValidationNotice(
                                  '请先为这一幕配置主角色，再开始预览。',
                                );
                                return;
                              }
                              setActiveSceneActPreviewIndex(index);
                            }}
                          />
                        </div>
                      </div>
                      <div className="mt-3 rounded-xl border border-white/8 bg-white/6 px-3 py-2 text-sm leading-6 text-zinc-100">
                        {act.eventDescription?.trim() ||
                          buildDefaultSceneActEventDescription({
                            sceneSummary: draft.description,
                            oppositeNpcId:
                              act.oppositeNpcId || act.primaryNpcId,
                            index,
                          })}
                      </div>
                    </div>
                  </div>
                </div>
              );
            })}
          </SectionPanel>
          <EditorInfoPanel
            title="场景连接关系"
            actions={
              <ActionButton
                label="查看世界地图"
                onClick={() => setIsWorldMapOpen(true)}
                tone="sky"
              />
            }
            headerClassName="flex-wrap"
            bodyClassName="mt-4"
          >
            <DirectionalSceneConnectionCompass
              centerName={draft.name || '当前场景'}
              directionTargets={directionTargetLabels}
              onDirectionClick={handleOpenDirectionPicker}
            />
          </EditorInfoPanel>
          <SaveBar
            onClose={handleRequestClose}
            onSave={saveLandmarkProfile}
            showClose={false}
          />
          {activeSceneActBackgroundDraft &&
          activeSceneActBackgroundIndex !== null ? (
            <SceneActBackgroundModal
              profile={editableProfile}
              landmark={draft}
              actLabel={
                activeSceneActBackgroundDraft.title.trim() ||
                buildDefaultSceneActTitle(activeSceneActBackgroundIndex)
              }
              act={activeSceneActBackgroundDraft}
              currentImageSrc={
                activeSceneActBackgroundDraft.backgroundImageSrc?.trim() || ''
              }
              fallbackImageSrc={compatibilityImageSrc || resolvedDraftImageSrc}
              onApply={(imageSrc, assetId) =>
                updateSceneActBackground(
                  activeSceneActBackgroundIndex,
                  imageSrc,
                  assetId,
                )
              }
              onClose={() => setActiveSceneActBackgroundIndex(null)}
            />
          ) : null}
          {activeSceneActSlotDraft && activeSceneActSlotPickerState ? (
            <SceneActNpcSlotPickerModal
              actLabel={
                activeSceneActSlotDraft.title.trim() ||
                buildDefaultSceneActTitle(
                  activeSceneActSlotPickerState.actIndex,
                )
              }
              slotIndex={activeSceneActSlotPickerState.slotIndex}
              currentNpcId={
                buildSceneActEncounterSlotIds(
                  activeSceneActSlotDraft.encounterNpcIds,
                )[activeSceneActSlotPickerState.slotIndex]
              }
              availableNpcs={sceneNpcOptions}
              onNotice={showValidationNotice}
              onApply={(npcId) =>
                updateSceneActField(
                  activeSceneActSlotPickerState.actIndex,
                  (current) => {
                    const encounterNpcIds = assignSceneActEncounterSlotId(
                      current.encounterNpcIds,
                      activeSceneActSlotPickerState.slotIndex,
                      npcId,
                    );

                    return {
                      ...current,
                      encounterNpcIds,
                      primaryNpcId: encounterNpcIds[0] ?? '',
                      oppositeNpcId: encounterNpcIds[0] ?? '',
                    };
                  },
                )
              }
              onClose={() => setActiveSceneActSlotPickerState(null)}
            />
          ) : null}
          {activeSceneActPreviewDraft && activeSceneActPreviewIndex !== null ? (
            <SceneActPreviewModal
              profile={previewProfile}
              landmark={draft}
              chapter={renderedSceneChapterDraft}
              actIndex={activeSceneActPreviewIndex}
              onClose={() => setActiveSceneActPreviewIndex(null)}
            />
          ) : null}
          {activeConnectionDirection ? (
            <SceneConnectionTargetPickerModal
              direction={activeConnectionDirection}
              landmarks={availableTargetLandmarks}
              currentTargetId={activeDirectionConnection?.targetLandmarkId}
              onSelect={(landmarkId) =>
                updateDirectionalConnection(
                  activeConnectionDirection,
                  landmarkId,
                )
              }
              onRemove={() =>
                removeDirectionalConnection(activeConnectionDirection)
              }
              onClose={() => setActiveConnectionDirection(null)}
            />
          ) : null}
          {isWorldMapOpen ? (
            <WorldMapOverviewModal
              landmarks={worldMapLandmarks}
              currentSceneId={draft.id}
              onClose={() => setIsWorldMapOpen(false)}
            />
          ) : null}
          {npcEditorState ? (
            <StoryNpcEditor
              profile={{
                ...profile,
                storyNpcs: draftStoryNpcs,
              }}
              npc={npcEditorState.npc}
              mode={npcEditorState.mode}
              onSave={(nextNpc) => {
                setDraftStoryNpcs((current) =>
                  npcEditorState.mode === 'create'
                    ? [...current, nextNpc]
                    : current.map((item) =>
                        item.id === nextNpc.id ? nextNpc : item,
                      ),
                );
                setNpcEditorState(null);
              }}
              onClose={() => setNpcEditorState(null)}
            />
          ) : null}
        </div>
      </ModalShell>
      {isCloseConfirmOpen ? (
        <CloseConfirmDialog
          message="当前场景修改尚未保存，确认关闭吗？"
          onCancel={() => setIsCloseConfirmOpen(false)}
          onConfirm={() => {
            setIsCloseConfirmOpen(false);
            onClose();
          }}
        />
      ) : null}
      {validationNotice ? (
        <EditorNoticeDialog
          message={validationNotice}
          onClose={() => setValidationNotice(null)}
        />
      ) : null}
    </>
  );
}

function RpgCreationEntityEditorModal({
  profile,
  target,
  onClose,
  onProfileChange,
}: RpgCreationEntityEditorModalProps) {
  if (!target) return null;

  if (target.kind === 'world') {
    return (
      <WorldEditor
        profile={profile}
        onSave={onProfileChange}
        onClose={onClose}
      />
    );
  }

  if (target.kind === 'foundation') {
    return (
      <WorldFoundationEditor
        profile={profile}
        onSave={onProfileChange}
        onClose={onClose}
      />
    );
  }

  if (target.kind === 'cover') {
    return (
      <WorldCoverEditor
        profile={profile}
        onSaveProfile={onProfileChange}
        onClose={onClose}
      />
    );
  }

  if (target.kind === 'camp') {
    return (
      <CampSceneEditor
        profile={profile}
        onSaveProfile={onProfileChange}
        onClose={onClose}
      />
    );
  }

  if (target.kind === 'playable') {
    const npc = resolveEditablePlayableNpc(profile, target);
    return npc ? (
      <PlayableNpcEditor
        profile={profile}
        npc={npc}
        mode={target.mode}
        onSave={(nextNpc) =>
          onProfileChange({
            ...profile,
            playableNpcs:
              target.mode === 'create'
                ? [...profile.playableNpcs, nextNpc]
                : profile.playableNpcs.map((item) =>
                    item.id === nextNpc.id ? nextNpc : item,
                  ),
          })
        }
        onClose={onClose}
      />
    ) : null;
  }

  if (target.kind === 'story') {
    const npc = resolveEditableStoryNpc(profile, target);
    return npc ? (
      <StoryNpcEditor
        profile={profile}
        npc={npc}
        mode={target.mode}
        onSave={(nextNpc) =>
          onProfileChange({
            ...profile,
            storyNpcs:
              target.mode === 'create'
                ? [...profile.storyNpcs, nextNpc]
                : profile.storyNpcs.map((item) =>
                    item.id === nextNpc.id ? nextNpc : item,
                  ),
          })
        }
        onClose={onClose}
      />
    ) : null;
  }

  const landmark = resolveEditableLandmark(profile, target);
  return landmark ? (
    <LandmarkEditor
      profile={profile}
      landmark={landmark}
      mode={target.mode}
      onSaveProfile={onProfileChange}
      onClose={onClose}
    />
  ) : null;
}

export { RpgCreationEntityEditorModal };
export default RpgCreationEntityEditorModal;
