﻿import type { ChangeEvent } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';

import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import {
  buildCustomWorldPlayableCharacters,
  ROLE_TEMPLATE_CHARACTERS,
} from '../data/characterPresets';
import {
  normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import {
  getAllCustomWorldSceneImages,
  resolveCustomWorldCampSceneImage,
  resolveCustomWorldLandmarkImage,
} from '../data/customWorldVisuals';
import { EDITOR_ITEM_CATALOG_API_PATH } from '../editor/shared/editorApiClient';
import { fetchJson } from '../editor/shared/jsonClient';
import {
  type CustomWorldSceneImageResult,
  generateCustomWorldSceneImage,
  generateCustomWorldSceneNpc,
} from '../services/aiService';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
  AnimationState,
  type Character,
  type CharacterAnimationConfig,
  CustomWorldLandmark,
  CustomWorldNpc,
  CustomWorldPlayableNpc,
  CustomWorldProfile,
  type CustomWorldRoleInitialItem,
  type CustomWorldRoleRelation,
  type CustomWorldRoleSkill,
  CustomWorldSceneConnection,
  type ItemRarity,
} from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { buildAnimationClipFromVideoSource } from './asset-studio/characterAssetWorkflowModel';
import {
  type CharacterAnimationGenerationPayload,
  generateCharacterAnimationDraft,
  publishCharacterAnimationAssets,
} from './asset-studio/characterAssetWorkflowPersistence';
import { CharacterAnimator } from './CharacterAnimator';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import {
  CustomWorldNpcPortrait,
  CustomWorldNpcVisualEditor,
} from './CustomWorldNpcVisualEditor';
import { CustomWorldRoleAssetStudioModal } from './CustomWorldRoleAssetStudioModal';
import { PixelIcon } from './PixelIcon';

export type CustomWorldEditorTarget =
  | { kind: 'world' }
  | { 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 };

interface CustomWorldEntityEditorModalProps {
  profile: CustomWorldProfile;
  target: CustomWorldEditorTarget | null;
  onClose: () => void;
  onProfileChange: (profile: CustomWorldProfile) => void;
}

const [
  BACKSTORY_UNLOCK_AFFINITY_EASED,
  BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
  BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
  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 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 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 buildSkillActionPrompt(params: {
  role: Pick<CustomWorldPlayableNpc | CustomWorldNpc, 'name' | 'title' | 'role' | 'description' | 'backstory' | 'personality' | 'motivation'>;
  skill: Pick<CustomWorldRoleSkill, 'name' | 'summary'>;
}) {
  const { role, skill } = params;
  return [
    `${role.name}，${role.title || role.role}。`,
    `技能名称：${skill.name}。`,
    skill.summary ? `技能表现：${skill.summary}。` : '',
    role.description ? `角色气质：${role.description}。` : '',
    role.personality ? `性格补充：${role.personality}。` : '',
    role.motivation ? `动作目标：${role.motivation}。` : '',
    '横版 RPG 角色技能动作，角色轮廓稳定，动作起手明确，过程连贯，收招干净，镜头稳定。',
  ]
    .filter(Boolean)
    .join(' ');
}

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,
  });
}

function useDraft<T>(value: T) {
  const [draft, setDraft] = useState(value);
  useEffect(() => setDraft(value), [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 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;
}) {
  return (
    <div
      className={`fixed inset-0 ${overlayClassName} flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4`}
      onClick={
        disableClose
          ? undefined
          : (event) => {
              if (event.target === event.currentTarget) {
                onClose();
              }
            }
      }
    >
      <div
        className={`pixel-nine-slice pixel-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)] ${usePixelFont ? 'fusion-pixel-app' : ''} ${panelClassName} sm:rounded-[1.75rem]`}
        style={getNineSliceStyle(UI_CHROME.modalPanel)}
        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>
          <button
            type="button"
            onClick={onClose}
            disabled={disableClose}
            className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
          >
            <PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
          </button>
        </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,
  overlayClassName = 'z-[140]',
  disableClose = false,
  usePixelFont = false,
}: {
  title: string;
  onClose: () => void;
  children: ReactNode;
  overlayClassName?: string;
  disableClose?: boolean;
  usePixelFont?: boolean;
}) {
  return (
    <div
      className={`fixed inset-0 ${overlayClassName} flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm`}
      onClick={
        disableClose
          ? undefined
          : (event) => {
              if (event.target === event.currentTarget) {
                onClose();
              }
            }
      }
    >
      <div
        className={`pixel-nine-slice pixel-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : ''}`}
        style={getNineSliceStyle(UI_CHROME.modalPanel)}
        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>
          <button
            type="button"
            onClick={onClose}
            disabled={disableClose}
            className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
          >
            <PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
          </button>
        </div>
        <div className="p-4">{children}</div>
      </div>
    </div>
  );
}

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

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

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>
      <button
        type="button"
        onClick={(event) => {
          event.preventDefault();
          setOpen((current) => !current);
        }}
        className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-white/16 bg-black/20 text-[10px] text-zinc-200 transition-colors hover:text-white"
        aria-label={`${label}说明`}
      >
        ?
      </button>
      {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,
}: {
  src?: string;
  alt: string;
  fallbackLabel: string;
  tone?: 'square' | 'landscape';
  children?: ReactNode;
}) {
  return (
    <div
      className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.95),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
    >
      {src ? (
        <img
          src={src}
          alt={alt}
          loading="lazy"
          className="h-full w-full object-cover"
        />
      ) : (
        <div className="flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400">
          {fallbackLabel}
        </div>
      )}
      {children ? (
        <div className="pointer-events-none absolute inset-0">{children}</div>
      ) : null}
    </div>
  );
}

function ImageField({
  label,
  value,
  onChange,
  fallbackLabel,
  tone = 'square',
  showInput = true,
  previewOverlay,
  footer,
}: {
  label: string;
  value?: string;
  onChange: (value: string) => void;
  fallbackLabel: string;
  tone?: 'square' | 'landscape';
  showInput?: boolean;
  previewOverlay?: ReactNode;
  footer?: ReactNode;
}) {
  return (
    <div className="space-y-3">
      <div className="text-[11px] font-bold tracking-[0.14em] text-zinc-300">
        {label}
      </div>
      <ImagePreview
        src={value}
        alt={label}
        fallbackLabel={fallbackLabel}
        tone={tone}
      >
        {previewOverlay}
      </ImagePreview>
      {showInput ? (
        <TextInput
          value={value ?? ''}
          onChange={onChange}
          placeholder="支持填写项目内图片路径或外链地址"
        />
      ) : null}
      {footer}
    </div>
  );
}

function ActionButton({
  label,
  onClick,
  tone = 'default',
  disabled = false,
}: {
  label: string;
  onClick: () => void;
  tone?: 'default' | 'sky' | 'rose';
  disabled?: boolean;
}) {
  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' : ''}`}
    >
      {label}
    </button>
  );
}

function SceneSparringPreview({ profile }: { profile: CustomWorldProfile }) {
  const sparringCharacters = useMemo(() => {
    const candidates = buildCustomWorldPlayableCharacters(profile);
    if (candidates.length >= 2) {
      return candidates.slice(0, 2);
    }

    if (candidates.length === 1) {
      const firstCandidate = candidates[0];
      if (!firstCandidate) {
        return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
      }
      const fallback =
        ROLE_TEMPLATE_CHARACTERS.find(
          (character) => character.id !== firstCandidate.id,
        ) ??
        ROLE_TEMPLATE_CHARACTERS[0] ??
        firstCandidate;
      return [firstCandidate, fallback];
    }

    return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
  }, [profile]);

  const [leftCharacter, rightCharacter] = sparringCharacters;
  if (!leftCharacter || !rightCharacter) {
    return null;
  }

  return (
    <>
      <div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,17,0.02)_0%,rgba(8,10,17,0.08)_42%,rgba(8,10,17,0.68)_100%)]" />
      <div className="absolute left-3 top-3 rounded-full border border-white/12 bg-black/46 px-3 py-1 text-[10px] font-bold tracking-[0.18em] text-zinc-100">
        对战预览
      </div>
      <div className="absolute left-1/2 top-[58%] h-16 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-300/30 blur-2xl animate-pulse" />
      <div className="absolute inset-x-0 bottom-0 h-20 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.72)_100%)]" />
      <div className="absolute inset-x-0 bottom-0 flex items-end justify-between px-2 pb-1 sm:px-4 sm:pb-2">
        <div className="flex flex-col items-center">
          <div className="h-24 w-24 sm:h-28 sm:w-28">
            <CharacterAnimator
              state={AnimationState.ATTACK}
              character={leftCharacter}
              className="h-full w-full"
              imageClassName="object-bottom drop-shadow-[0_10px_18px_rgba(0,0,0,0.45)]"
            />
          </div>
        </div>

        <div className="h-8 w-8 rounded-full bg-amber-300/18 blur-xl" />

        <div className="flex flex-col items-center">
          <div className="h-24 w-24 scale-x-[-1] sm:h-28 sm:w-28">
            <CharacterAnimator
              state={AnimationState.ATTACK}
              character={rightCharacter}
              className="h-full w-full"
              imageClassName="object-bottom drop-shadow-[0_10px_18px_rgba(0,0,0,0.45)]"
            />
          </div>
        </div>
      </div>
    </>
  );
}

function ScenePresetPickerModal({
  selectedSrc,
  presetImages,
  onSelect,
  onClose,
}: {
  selectedSrc?: string;
  presetImages: string[];
  onSelect: (value: string) => void;
  onClose: () => void;
}) {
  return (
    <ModalShell
      title="基于预设素材修改"
      onClose={onClose}
    >
      <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
        {presetImages.map((src, index) => {
          const isSelected = src === selectedSrc;
          return (
            <button
              key={src}
              type="button"
              onClick={() => {
                onSelect(src);
                onClose();
              }}
              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'
              }`}
            >
              <div className="relative aspect-[16/9] overflow-hidden">
                <img
                  src={src}
                  alt={`Scene preset ${index + 1}`}
                  loading="lazy"
                  className="h-full w-full object-cover"
                />
                <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>
                {isSelected ? (
                  <div className="absolute right-2 top-2 rounded-full border border-sky-200/40 bg-black/55 px-2 py-0.5 text-[10px] font-semibold text-sky-50">
                    已选中
                  </div>
                ) : null}
              </div>
            </button>
          );
        })}
      </div>
    </ModalShell>
  );
}

function SceneNpcPickerModal({
  storyNpcs,
  selectedNpcIds,
  onApply,
  onClose,
}: {
  storyNpcs: CustomWorldNpc[];
  selectedNpcIds: string[];
  onApply: (npcIds: string[]) => void;
  onClose: () => void;
}) {
  const [draftSelection, setDraftSelection] = useState<string[]>(selectedNpcIds);

  useEffect(() => {
    setDraftSelection(selectedNpcIds);
  }, [selectedNpcIds]);

  return (
    <ModalShell title="添加场景角色" onClose={onClose} panelClassName="sm:max-w-3xl">
      <div className="space-y-4">
        <div className="grid gap-3 sm:grid-cols-2">
          {storyNpcs.map((npc) => {
            const isSelected = draftSelection.includes(npc.id);
            return (
              <button
                key={npc.id}
                type="button"
                onClick={() =>
                  setDraftSelection((current) =>
                    current.includes(npc.id)
                      ? current.filter((entry) => entry !== npc.id)
                      : [...current, 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 justify-between gap-3">
                  <div className="min-w-0">
                    <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>
                    {npc.description ? (
                      <div className="mt-2 text-xs leading-6 text-zinc-400">
                        {npc.description}
                      </div>
                    ) : null}
                  </div>
                  <div
                    className={`rounded-full border px-2.5 py-1 text-[10px] ${
                      isSelected
                        ? 'border-sky-300/30 bg-sky-500/12 text-sky-50'
                        : 'border-white/10 bg-black/20 text-zinc-400'
                    }`}
                  >
                    {isSelected ? '已选择' : '未选择'}
                  </div>
                </div>
              </button>
            );
          })}
        </div>
        <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
          <ActionButton label="取消" onClick={onClose} />
          <ActionButton
            label={`加入场景（${draftSelection.length}）`}
            onClick={() => {
              onApply(draftSelection);
              onClose();
            }}
            tone="sky"
          />
        </div>
      </div>
    </ModalShell>
  );
}

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 ? (
                  <div className="rounded-full border border-sky-300/30 bg-sky-500/12 px-2.5 py-1 text-[10px] text-sky-50">
                    当前连接
                  </div>
                ) : 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;
  left: number;
  top: number;
  centerX: number;
  centerY: number;
};

type WorldMapEdgeLayout = {
  fromId: string;
  toId: string;
};

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[]) {
  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,
      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)) {
        edgeMap.set(pairKey, {
          fromId: sourceId,
          toId: connection.targetLandmarkId,
        });
      }
    });
  });

  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,
  onClose,
}: {
  landmarks: CustomWorldLandmark[];
  onClose: () => void;
}) {
  const { nodes, edges, width, height } = useMemo(
    () => buildWorldMapLayout(landmarks),
    [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-3xl border border-white/8 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.08),transparent_42%),linear-gradient(180deg,rgba(5,8,15,0.96),rgba(6,10,18,0.92))] p-4">
        <div
          className="relative"
          style={{
            width: `${width}px`,
            height: `${height}px`,
            minWidth: '100%',
          }}
        >
          <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="rgba(125, 211, 252, 0.45)"
                  strokeWidth="2"
                  strokeLinecap="round"
                />
              );
            })}
          </svg>

          {nodes.map((node) => (
            <div
              key={node.id}
              className="absolute rounded-[1.6rem] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.94),rgba(9,13,24,0.98))] px-4 py-3 shadow-[0_16px_40px_rgba(0,0,0,0.35)]"
              style={{
                left: `${node.left}px`,
                top: `${node.top}px`,
                width: `${WORLD_MAP_NODE_WIDTH}px`,
                minHeight: `${WORLD_MAP_NODE_HEIGHT}px`,
              }}
            >
              <div className="text-sm font-semibold text-white">{node.name}</div>
              {node.description ? (
                <div className="mt-2 line-clamp-2 text-xs leading-5 text-zinc-400">
                  {node.description}
                </div>
              ) : null}
            </div>
          ))}
        </div>
      </div>
    </ModalShell>
  );
}

const FIXED_SCENE_IMAGE_SIZE = '1280*720';

function SceneImageGenerationModal({
  profile,
  landmark,
  onApply,
  onClose,
}: {
  profile: CustomWorldProfile;
  landmark: CustomWorldLandmark;
  onApply: (result: CustomWorldSceneImageResult) => void;
  onClose: () => void;
}) {
  const [userPrompt, setUserPrompt] = useDraft(
    landmark.name.trim() || landmark.description.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 originalImageSrc = useMemo(() => {
    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)),
    );
  }, [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 generateCustomWorldSceneImage({
        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-5xl"
        overlayClassName="z-[99]"
        disableClose={isGenerating}
        usePixelFont
      >
        <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={8}
                placeholder="例如：雨夜的悬桥横跨黑色峡谷，桥下翻涌蓝绿色雾潮，远处有半坍塌塔楼与零星灯火。"
              />
            </Field>

            <Field label="自定义参考图（可选）">
              <div className="space-y-3">
                <label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
                  <input
                    type="file"
                    accept="image/png,image/jpeg,image/webp"
                    onChange={(event) => {
                      void handleReferenceImageChange(event);
                    }}
                    className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
                  />
                </label>
                {referenceImageSrc ? (
                  <div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
                    <div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
                      <img
                        src={referenceImageSrc}
                        alt="自定义参考图"
                        className="h-full w-full object-cover"
                      />
                    </div>
                    <div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
                      已载入自定义参考图
                    </div>
                    <ActionButton
                      label="移除"
                      onClick={() => setReferenceImageSrc('')}
                      disabled={isGenerating}
                    />
                  </div>
                ) : 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 ? (
              <div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
                已生成完毕，请保存后再退出页面
              </div>
            ) : null}

            {error ? (
              <div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
                {error}
              </div>
            ) : 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 ? (
        <PortalCompactDialogShell
          title="确认退出"
          onClose={() => setIsExitConfirmOpen(false)}
          overlayClassName="z-[140]"
          usePixelFont
        >
          <div className="space-y-4">
            <div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
              当前生成画面还未保存，退出后将丢失这次生成结果，仍然退出吗？
            </div>
            <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
              <ActionButton
                label="继续编辑"
                onClick={() => setIsExitConfirmOpen(false)}
              />
              <ActionButton
                label="仍然退出"
                onClick={() => {
                  setIsExitConfirmOpen(false);
                  onClose();
                }}
                tone="sky"
              />
            </div>
          </div>
        </PortalCompactDialogShell>
      ) : null}
    </>
  );
}

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-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 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}
          <button
            type="button"
            onClick={onSave}
            className="pixel-nine-slice pixel-pressable text-left"
            style={getNineSliceStyle(UI_CHROME.choiceButton, {
              paddingX: 16,
              paddingY: 10,
            })}
          >
            <div className="flex items-center justify-between gap-4">
              <span className="text-sm font-semibold text-white">保存修改</span>
              <span className="text-white/60">→</span>
            </div>
          </button>
        </div>
      </div>
    </div>
  );
}

function SectionPanel({
  title,
  subtitle,
  actions,
  children,
}: {
  title: string;
  subtitle?: string;
  actions?: ReactNode;
  children: ReactNode;
}) {
  return (
    <div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
      <div className="flex flex-wrap items-start justify-between gap-3">
        <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>
        {actions}
      </div>
      <div className="mt-4 space-y-3">{children}</div>
    </div>
  );
}

function buildRolePreviewCharacter(
  role: CustomWorldPlayableNpc | CustomWorldNpc,
): Character | null {
  const template =
    'templateCharacterId' in role && role.templateCharacterId
      ? ROLE_TEMPLATE_CHARACTERS.find(
          (entry) => entry.id === role.templateCharacterId,
        ) ?? null
      : null;
  const portrait = role.imageSrc || template?.portrait;

  if (!portrait) {
    return null;
  }

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

function BackstoryRevealEditor({
  value,
  onChange,
}: {
  value: CustomWorldPlayableNpc['backstoryReveal'];
  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) {
      window.alert('至少保留一个背景章节。');
      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>
        ))
      ) : (
        <div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
          还没有配置与其他角色的关系。
        </div>
      )}
    </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 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: [],
        frameCount: 8,
        fps: 10,
        durationSeconds: 3,
        loop: false,
        useChromaKey: true,
        resolution: '720P',
        imageSequenceModel: 'wan2.7-image-pro',
        videoModel: 'wan2.7-i2v',
        referenceVideoModel: 'wan2.7-r2v',
        motionTransferModel: 'wan2.2-animate-move',
      } satisfies CharacterAnimationGenerationPayload);

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

      const clip = await buildAnimationClipFromVideoSource(
        generationResult.previewVideoPath,
        {
          animation: AnimationState.ATTACK,
          fps: 10,
          loop: false,
          frameCount: 8,
          applyChromaKey: true,
        },
      );

      const publishResult = await publishCharacterAnimationAssets({
        characterId: role.id,
        visualAssetId: role.generatedVisualAssetId,
        animations: {
          [actionKey]: {
            framesDataUrls: clip.frames,
            fps: clip.fps,
            loop: clip.loop,
            frameWidth: clip.frameWidth,
            frameHeight: clip.frameHeight,
            previewVideoPath: clip.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-[10rem] items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3">
            {previewCharacter && draft.actionPreviewConfig ? (
              <div className="h-40 w-40">
                <CharacterAnimator
                  state={AnimationState.ATTACK}
                  character={previewCharacter}
                  className="h-full w-full"
                />
              </div>
            ) : role.imageSrc ? (
              <img
                src={role.imageSrc}
                alt={role.name}
                className="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 ? (
                    <img
                      src={role.imageSrc}
                      alt={skill.name}
                      className="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">
                  <StatusBadge
                    label={
                      skill.actionPreviewConfig ? '动作已生成' : '待生成动作'
                    }
                    tone={skill.actionPreviewConfig ? 'ready' : 'idle'}
                  />
                </div>
              </div>
            </button>
          );
        })
      ) : (
        <div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
          还没有配置角色技能。
        </div>
      )}
      {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 StatusBadge({
  label,
  tone,
}: {
  label: string;
  tone: 'ready' | 'idle';
}) {
  return (
    <span
      className={`rounded-full border px-2.5 py-1 text-[10px] ${
        tone === 'ready'
          ? 'border-emerald-400/24 bg-emerald-500/10 text-emerald-100'
          : 'border-white/10 bg-black/20 text-zinc-400'
      }`}
    >
      {label}
    </span>
  );
}

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>
              <div className="mt-2 flex flex-wrap gap-2">
                {draft.tags.map((tag) => (
                  <span
                    key={`${draft.id}-${tag}`}
                    className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
                  >
                    {tag}
                  </span>
                ))}
              </div>
            </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>
                <div className="mt-2 flex flex-wrap gap-2">
                  {item.tags.map((tag) => (
                    <span
                      key={`${item.id}-${tag}`}
                      className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
                    >
                      {tag}
                    </span>
                  ))}
                </div>
              </div>
              <div className="text-xs text-zinc-200">x{item.quantity}</div>
            </div>
          </button>
        ))
      ) : (
        <div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
          还没有配置角色物品。
        </div>
      )}
      {editingItemIndex !== null && value[editingItemIndex] ? (
        <RoleInitialItemEditorModal
          item={value[editingItemIndex]!}
          onSave={(nextItem) =>
            onChange(
              value.map((item, itemIndex) =>
                itemIndex === editingItemIndex ? nextItem : item,
              ),
            )
          }
          onClose={() => setEditingItemIndex(null)}
        />
      ) : null}
    </SectionPanel>
  );
}

function StoryNpcVisualEditorModal({
  npc,
  visual,
  onChange,
  onOpenAiStudio,
  onClose,
}: {
  npc: CustomWorldNpc;
  visual: NonNullable<CustomWorldNpc['visual']>;
  onChange: (visual: NonNullable<CustomWorldNpc['visual']>) => void;
  onOpenAiStudio?: () => void;
  onClose: () => void;
}) {
  return (
    <ModalShell
      title={`修改形象：${npc.name}`}
      subtitle="在独立面板中组合中世纪奇幻角色形象，左侧预览会保持吸顶。"
      onClose={onClose}
      panelClassName="sm:max-w-6xl"
      overlayClassName="z-[99]"
    >
      <CustomWorldNpcVisualEditor
        npc={{
          id: npc.id,
          name: npc.name,
          role: npc.role,
          description: npc.description,
        }}
        value={visual}
        onChange={onChange}
        onAiGenerate={() => {
          onClose();
          onOpenAiStudio?.();
        }}
      />
    </ModalShell>
  );
}

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

  return (
    <ModalShell
      title="编辑世界信息"
      subtitle="修改后的内容会直接反映在结果页，并会作为进入世界前的最终档案。"
      onClose={onClose}
    >
      <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>
        <Field label="玩家原始设定">
          <TextArea
            value={draft.settingText}
            onChange={(value) =>
              setDraft((current) => ({
                ...current,
                settingText: value,
                creatorIntent: current.creatorIntent
                  ? {
                      ...current.creatorIntent,
                      rawSettingText: value,
                    }
                  : current.creatorIntent,
              }))
            }
            rows={4}
          />
        </Field>
        <SaveBar
          onClose={onClose}
          onSave={() => {
            onSave(draft);
            onClose();
          }}
        />
      </div>
    </ModalShell>
  );
}

function CampSceneEditor({
  profile,
  onSaveProfile,
  onClose,
}: {
  profile: CustomWorldProfile;
  onSaveProfile: (profile: CustomWorldProfile) => void;
  onClose: () => void;
}) {
  const [draft, setDraft] = useDraft(resolveCustomWorldCampScene(profile));
  const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
  const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
  const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
  const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
  const resolvedDraftImageSrc = useMemo(
    () =>
      draft.imageSrc?.trim()
        ? draft.imageSrc
        : resolveCustomWorldCampSceneImage({
            ...profile,
            camp: draft,
          }),
    [draft, profile],
  );
  const draftSnapshot = useMemo(
    () => JSON.stringify(draft),
    [draft],
  );
  const initialSnapshot = useMemo(
    () => JSON.stringify(resolveCustomWorldCampScene(profile)),
    [profile],
  );
  const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
  const campSceneDraft = useMemo<CustomWorldLandmark>(
    () => ({
      id: 'custom-scene-camp',
      name: draft.name,
      description: draft.description,
      dangerLevel: draft.dangerLevel,
      imageSrc: draft.imageSrc,
      sceneNpcIds: [],
      connections: [],
    }),
    [draft],
  );

  const handleRequestClose = () => {
    if (!hasUnsavedChanges) {
      onClose();
      return;
    }
    setIsCloseConfirmOpen(true);
  };

  return (
    <>
      <ModalShell
        title={`编辑场景：${draft.name || '开局场景'}`}
        onClose={handleRequestClose}
      >
        <div className="space-y-4">
          <ImageField
            label="场景图片"
            value={resolvedDraftImageSrc}
            onChange={(value) =>
              setDraft((current) => ({
                ...current,
                imageSrc: value || undefined,
              }))
            }
            fallbackLabel={draft.name ? draft.name.slice(0, 4) : '场景'}
            tone="landscape"
            showInput={false}
            previewOverlay={
              <SceneSparringPreview
                profile={{
                  ...profile,
                  camp: draft,
                }}
              />
            }
            footer={
              <div className="flex flex-wrap gap-3">
                <ActionButton
                  label="基于预设素材修改"
                  onClick={() => setIsPresetPickerOpen(true)}
                  tone="sky"
                />
                <ActionButton
                  label="AI生成"
                  onClick={() => setIsAiGenerateOpen(true)}
                />
              </div>
            }
          />
          <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>
          <SaveBar
            onClose={handleRequestClose}
            onSave={() => {
              onSaveProfile({
                ...profile,
                camp: draft,
              });
              onClose();
            }}
            showClose={false}
          />
          {isPresetPickerOpen ? (
            <ScenePresetPickerModal
              selectedSrc={draft.imageSrc}
              presetImages={presetImages}
              onSelect={(value) =>
                setDraft((current) => ({ ...current, imageSrc: value }))
              }
              onClose={() => setIsPresetPickerOpen(false)}
            />
          ) : null}
          {isAiGenerateOpen ? (
            <SceneImageGenerationModal
              profile={{
                ...profile,
                camp: draft,
              }}
              landmark={campSceneDraft}
              onApply={(result) => {
                setDraft((current) => ({
                  ...current,
                  imageSrc: result.imageSrc,
                }));
              }}
              onClose={() => setIsAiGenerateOpen(false)}
            />
          ) : null}
        </div>
      </ModalShell>
      {isCloseConfirmOpen ? (
        <PortalCompactDialogShell
          title="确认关闭"
          onClose={() => setIsCloseConfirmOpen(false)}
          overlayClassName="z-[140]"
        >
          <div className="space-y-4">
            <div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
              当前场景修改尚未保存，确认关闭吗？
            </div>
            <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
              <ActionButton
                label="继续编辑"
                onClick={() => setIsCloseConfirmOpen(false)}
              />
              <ActionButton
                label="确认关闭"
                onClick={() => {
                  setIsCloseConfirmOpen(false);
                  onClose();
                }}
                tone="sky"
              />
            </div>
          </div>
        </PortalCompactDialogShell>
      ) : null}
    </>
  );
}

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 selectedTemplate =
    ROLE_TEMPLATE_CHARACTERS.find(
      (character) => character.id === draft.templateCharacterId,
    ) ??
    ROLE_TEMPLATE_CHARACTERS[0] ??
    null;
  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 = () => {
    setIsCloseConfirmOpen(true);
  };

  return (
    <>
      <ModalShell
        title={mode === 'create' ? '新增可扮演角色' : `编辑角色：${npc.name}`}
        onClose={handleRequestClose}
        disableClose={isAiAssetStudioOpen || isCloseConfirmOpen}
      >
      <div className="space-y-4">
        {selectedTemplate ? (
          <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)]">
              <div className="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
                <img
                  src={draft.imageSrc || selectedTemplate.portrait}
                  alt={selectedTemplate.name}
                  className="h-28 w-full object-cover object-top"
                />
              </div>
              <div className="min-w-0">
                <div className="text-base font-semibold text-white">
                  {selectedTemplate.name}
                </div>
                <div className="mt-1 text-sm text-zinc-400">
                  {selectedTemplate.title}
                </div>
                <div className="mt-3 text-sm leading-6 text-zinc-300">
                  {selectedTemplate.description}
                </div>
                <div className="mt-3 flex flex-wrap gap-2">
                  {draft.generatedVisualAssetId ? (
                    <span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
                      已应用主图
                    </span>
                  ) : null}
                  {draft.generatedAnimationSetId ? (
                    <span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
                      已应用动作
                    </span>
                  ) : null}
                </div>
                <div className="mt-3">
                  <ActionButton
                    label="AI生成"
                    onClick={() => setIsAiAssetStudioOpen(true)}
                    tone="sky"
                  />
                </div>
              </div>
            </div>
          </div>
        ) : null}

        <Field label="外观模板">
          <SelectField
            value={draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id ?? ''}
            onChange={(value) =>
              setDraft((current) => ({
                ...current,
                templateCharacterId: value,
              }))
            }
            options={ROLE_TEMPLATE_CHARACTERS.map((character) => ({
              value: character.id,
              label: `${character.name} / ${character.title}`,
            }))}
          />
        </Field>
        <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}
          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,
              templateCharacterId:
                draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id,
            });
            onClose();
          }}
          showClose={false}
        />
        {isAiAssetStudioOpen ? (
          <CustomWorldRoleAssetStudioModal
            role={draft}
            roleKind="playable"
            onApply={(nextRole) =>
              setDraft((current) => ({
                ...current,
                ...nextRole,
              }))
            }
            onClose={() => setIsAiAssetStudioOpen(false)}
          />
        ) : null}
      </div>
      </ModalShell>
      {isCloseConfirmOpen ? (
        <PortalCompactDialogShell
          title="确认关闭"
          onClose={() => setIsCloseConfirmOpen(false)}
          overlayClassName="z-[140]"
        >
          <div className="space-y-4">
            <div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
              当前修改尚未保存，确认关闭吗？
            </div>
            <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
              <ActionButton
                label="继续编辑"
                onClick={() => setIsCloseConfirmOpen(false)}
              />
              <ActionButton
                label="确认关闭"
                onClick={() => {
                  setIsCloseConfirmOpen(false);
                  onClose();
                }}
                tone="sky"
              />
            </div>
          </div>
        </PortalCompactDialogShell>
      ) : null}
    </>
  );
}

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 [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
  const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
  const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
  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 = () => {
    setIsCloseConfirmOpen(true);
  };

  return (
    <>
      <ModalShell
        title={mode === 'create' ? '新增场景角色' : `编辑场景角色：${npc.name}`}
        onClose={handleRequestClose}
        disableClose={
          isVisualEditorOpen || isAiAssetStudioOpen || isCloseConfirmOpen
        }
      >
      <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={() => setIsVisualEditorOpen(true)}
                  tone="sky"
                />
                <ActionButton
                  label="AI生成"
                  onClick={() => setIsAiAssetStudioOpen(true)}
                  tone="sky"
                />
              </div>
              <div className="flex flex-wrap gap-2">
                {draft.generatedVisualAssetId ? (
                  <span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
                    已应用主图
                  </span>
                ) : null}
                {draft.generatedAnimationSetId ? (
                  <span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
                    已应用动作
                  </span>
                ) : null}
              </div>
            </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}
          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}
        />
        {isVisualEditorOpen ? (
          <StoryNpcVisualEditorModal
            npc={draft}
            visual={
              draft.visual ??
              buildDefaultCustomWorldNpcVisual({
                id: draft.id,
                name: draft.name,
                role: draft.role,
                description: draft.description,
              })
            }
            onChange={(visual) =>
              setDraft((current) => ({ ...current, visual }))
            }
            onOpenAiStudio={() => setIsAiAssetStudioOpen(true)}
            onClose={() => setIsVisualEditorOpen(false)}
          />
        ) : null}
        {isAiAssetStudioOpen ? (
          <CustomWorldRoleAssetStudioModal
            role={draft}
            roleKind="story"
            onApply={(nextRole) =>
              setDraft((current) => ({
                ...current,
                ...nextRole,
              }))
            }
            onClose={() => setIsAiAssetStudioOpen(false)}
          />
        ) : null}
      </div>
      </ModalShell>
      {isCloseConfirmOpen ? (
        <PortalCompactDialogShell
          title="确认关闭"
          onClose={() => setIsCloseConfirmOpen(false)}
          overlayClassName="z-[140]"
        >
          <div className="space-y-4">
            <div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
              当前修改尚未保存，确认关闭吗？
            </div>
            <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
              <ActionButton
                label="继续编辑"
                onClick={() => setIsCloseConfirmOpen(false)}
              />
              <ActionButton
                label="确认关闭"
                onClick={() => {
                  setIsCloseConfirmOpen(false);
                  onClose();
                }}
                tone="sky"
              />
            </div>
          </div>
        </PortalCompactDialogShell>
      ) : null}
    </>
  );
}

function LandmarkEditor({
  profile,
  landmark,
  mode,
  onSaveProfile,
  onClose,
}: {
  profile: CustomWorldProfile;
  landmark: CustomWorldLandmark;
  mode: 'create' | 'edit';
  onSaveProfile: (profile: CustomWorldProfile) => void;
  onClose: () => void;
}) {
  const [draft, setDraft] = useDraft(landmark);
  const [draftStoryNpcs, setDraftStoryNpcs] = useDraft(profile.storyNpcs);
  const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
  const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
  const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
  const [isNpcPickerOpen, setIsNpcPickerOpen] = useState(false);
  const [isWorldMapOpen, setIsWorldMapOpen] = useState(false);
  const [isGeneratingSceneNpc, setIsGeneratingSceneNpc] = useState(false);
  const [activeConnectionDirection, setActiveConnectionDirection] =
    useState<CardinalConnectionDirection | null>(null);
  const [npcEditorState, setNpcEditorState] = useState<{
    mode: 'create' | 'edit';
    npc: CustomWorldNpc;
  } | null>(null);
  const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
  const resolvedDraftImageSrc = useMemo(() => {
    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, profile]);
  const storyNpcById = useMemo(
    () => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])),
    [draftStoryNpcs],
  );
  const availableTargetLandmarks = useMemo(
    () => profile.landmarks.filter((entry) => entry.id !== draft.id),
    [draft.id, profile.landmarks],
  );
  const editableProfile = useMemo(() => {
    const nextLandmarks =
      mode === 'create'
        ? [...profile.landmarks, draft]
        : profile.landmarks.map((entry) =>
            entry.id === draft.id ? draft : entry,
          );

    return {
      ...profile,
      storyNpcs: draftStoryNpcs,
      landmarks: nextLandmarks,
    };
  }, [draft, draftStoryNpcs, mode, profile]);
  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 hasUnsavedChanges =
    initialLandmarkSnapshot !== currentLandmarkSnapshot ||
    initialStoryNpcSnapshot !== currentStoryNpcSnapshot;

  const handleRequestClose = () => {
    if (!hasUnsavedChanges) {
      onClose();
      return;
    }
    setIsCloseConfirmOpen(true);
  };

  const removeSceneNpc = (npcId: string) => {
    setDraft((current) => ({
      ...current,
      sceneNpcIds: current.sceneNpcIds.filter((entry) => entry !== npcId),
    }));
  };

  const applySceneNpcSelection = (npcIds: string[]) => {
    setDraft((current) => ({
      ...current,
      sceneNpcIds: [...new Set(npcIds)],
    }));
  };

  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) {
      window.alert('请先保留至少一个其他场景，才能配置连接关系。');
      return;
    }
    setActiveConnectionDirection(direction);
  };

  const handleGenerateSceneNpc = async () => {
    setIsGeneratingSceneNpc(true);

    try {
      const nextNpc = await generateCustomWorldSceneNpc({
        profile: editableProfile,
        landmarkId: draft.id,
      });

      setDraftStoryNpcs((current) => [...current, nextNpc]);
      setDraft((current) => ({
        ...current,
        sceneNpcIds: current.sceneNpcIds.includes(nextNpc.id)
          ? current.sceneNpcIds
          : [...current.sceneNpcIds, nextNpc.id],
      }));
      setNpcEditorState({
        mode: 'edit',
        npc: nextNpc,
      });
    } catch (error) {
      window.alert(
        error instanceof Error ? error.message : '生成场景 NPC 失败，请稍后重试。',
      );
    } finally {
      setIsGeneratingSceneNpc(false);
    }
  };

  const saveLandmarkProfile = () => {
    const sanitizedDraft = {
      ...draft,
      sceneNpcIds: [...new Set(draft.sceneNpcIds)],
      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 (sanitizedDraft.sceneNpcIds.length < 3) {
      window.alert('每个场景至少需要分配 3 个 NPC。');
      return;
    }

    const nextLandmarks =
      mode === 'create'
        ? [...profile.landmarks, sanitizedDraft]
        : profile.landmarks.map((entry) =>
            entry.id === sanitizedDraft.id ? sanitizedDraft : entry,
          );

    onSaveProfile({
      ...profile,
      storyNpcs: draftStoryNpcs,
      landmarks: syncLandmarksWithStoryNpcs(nextLandmarks, draftStoryNpcs),
    });
    onClose();
  };

  return (
    <>
      <ModalShell
        title={mode === 'create' ? '新增场景' : `编辑场景：${landmark.name}`}
        onClose={handleRequestClose}
      >
        <div className="space-y-4">
          <ImageField
            label="场景图片"
            value={resolvedDraftImageSrc}
            onChange={(value) =>
              setDraft((current) => ({
                ...current,
                imageSrc: value || undefined,
              }))
            }
            fallbackLabel={draft.name ? draft.name.slice(0, 4) : '背景'}
            tone="landscape"
            showInput={false}
            previewOverlay={<SceneSparringPreview profile={editableProfile} />}
            footer={
              <div className="flex flex-wrap gap-3">
                <ActionButton
                  label="基于预设素材修改"
                  onClick={() => setIsPresetPickerOpen(true)}
                  tone="sky"
                />
                <ActionButton
                  label="AI生成"
                  onClick={() => setIsAiGenerateOpen(true)}
                />
              </div>
            }
          />
          <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>
          <div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
            <div className="flex flex-wrap items-center justify-between gap-3">
              <div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
                场景内 NPC
              </div>
              <div className="flex flex-wrap gap-3">
                <ActionButton
                  label="添加"
                  onClick={() => setIsNpcPickerOpen(true)}
                  tone="sky"
                />
                <ActionButton
                  label={
                    isGeneratingSceneNpc
                      ? 'AI生成中...'
                      : 'AI生成新NPC并加入场景'
                  }
                  onClick={handleGenerateSceneNpc}
                  tone="sky"
                  disabled={isGeneratingSceneNpc}
                />
              </div>
            </div>
            <div className="mt-3 space-y-2">
              {draft.sceneNpcIds.length > 0 ? (
                draft.sceneNpcIds.map((npcId) => {
                  const npc = storyNpcById.get(npcId);
                  return (
                    <div
                      key={`${draft.id}-selected-npc-${npcId}`}
                      className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
                    >
                      <div className="flex flex-wrap items-center justify-between gap-3">
                        <div className="min-w-0">
                          <div className="text-sm font-semibold text-white">
                            {npc?.name ?? '未匹配场景角色'}
                          </div>
                          <div className="mt-1 text-xs leading-6 text-zinc-400">
                            {npc?.role || npc?.title || '未填写身份'}
                          </div>
                        </div>
                        <div className="flex flex-wrap gap-2">
                          {npc ? (
                            <ActionButton
                              label="编辑"
                              onClick={() =>
                                setNpcEditorState({
                                  mode: 'edit',
                                  npc,
                                })
                              }
                              tone="sky"
                            />
                          ) : null}
                          <ActionButton
                            label="移出场景"
                            onClick={() => removeSceneNpc(npcId)}
                          />
                        </div>
                      </div>
                    </div>
                  );
                })
              ) : (
                <div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500">
                  还没有为这个场景分配 NPC。
                </div>
              )}
            </div>
          </div>
          <div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
            <div className="flex flex-wrap items-center justify-between gap-3">
              <div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
                场景连接关系
              </div>
              <ActionButton
                label="查看世界地图"
                onClick={() => setIsWorldMapOpen(true)}
                tone="sky"
              />
            </div>
            <div className="mt-4">
              <DirectionalSceneConnectionCompass
                centerName={draft.name || '当前场景'}
                directionTargets={directionTargetLabels}
                onDirectionClick={handleOpenDirectionPicker}
              />
            </div>
          </div>
          <SaveBar
            onClose={handleRequestClose}
            onSave={saveLandmarkProfile}
            showClose={false}
          />
          {isPresetPickerOpen ? (
            <ScenePresetPickerModal
              selectedSrc={draft.imageSrc}
              presetImages={presetImages}
              onSelect={(value) =>
                setDraft((current) => ({ ...current, imageSrc: value }))
              }
              onClose={() => setIsPresetPickerOpen(false)}
            />
          ) : null}
          {isAiGenerateOpen ? (
            <SceneImageGenerationModal
              profile={editableProfile}
              landmark={draft}
              onApply={(result) => {
                setDraft((current) => ({
                  ...current,
                  imageSrc: result.imageSrc,
                }));
              }}
              onClose={() => setIsAiGenerateOpen(false)}
            />
          ) : null}
          {isNpcPickerOpen ? (
            <SceneNpcPickerModal
              storyNpcs={draftStoryNpcs}
              selectedNpcIds={draft.sceneNpcIds}
              onApply={applySceneNpcSelection}
              onClose={() => setIsNpcPickerOpen(false)}
            />
          ) : 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={editableProfile.landmarks}
              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,
                      ),
                );
                setDraft((current) => ({
                  ...current,
                  sceneNpcIds: current.sceneNpcIds.includes(nextNpc.id)
                    ? current.sceneNpcIds
                    : [...current.sceneNpcIds, nextNpc.id],
                }));
                setNpcEditorState(null);
              }}
              onClose={() => setNpcEditorState(null)}
            />
          ) : null}
        </div>
      </ModalShell>
      {isCloseConfirmOpen ? (
        <PortalCompactDialogShell
          title="确认关闭"
          onClose={() => setIsCloseConfirmOpen(false)}
          overlayClassName="z-[140]"
        >
          <div className="space-y-4">
            <div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
              当前场景修改尚未保存，确认关闭吗？
            </div>
            <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
              <ActionButton
                label="继续编辑"
                onClick={() => setIsCloseConfirmOpen(false)}
              />
              <ActionButton
                label="确认关闭"
                onClick={() => {
                  setIsCloseConfirmOpen(false);
                  onClose();
                }}
                tone="sky"
              />
            </div>
          </div>
        </PortalCompactDialogShell>
      ) : null}
    </>
  );
}

function createPlayableNpc(
  profile: CustomWorldProfile,
): CustomWorldPlayableNpc {
  const seed = Date.now() + profile.playableNpcs.length;
  const template =
    ROLE_TEMPLATE_CHARACTERS[
      profile.playableNpcs.length % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length)
    ] ?? ROLE_TEMPLATE_CHARACTERS[0];

  return {
    id: createEntryId(
      'playable-npc',
      `角色-${profile.playableNpcs.length + 1}`,
      seed,
    ),
    name: `自定义角色${profile.playableNpcs.length + 1}`,
    title: '自定义身份',
    role: '世界中的行动者',
    description: '',
    backstory: '',
    personality: '',
    motivation: '',
    combatStyle: '',
    initialAffinity: 18,
    relationshipHooks: ['首次接触', '合作空间'],
    relations: [],
    tags: ['自定义'],
    backstoryReveal: {
      publicSummary: '',
      chapters: [
        {
          id: 'surface',
          title: '表层来意',
          affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
          teaser: '',
          content: '',
          contextSnippet: '',
        },
        {
          id: 'scar',
          title: '旧事裂痕',
          affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
          teaser: '',
          content: '',
          contextSnippet: '',
        },
        {
          id: 'hidden',
          title: '隐藏执念',
          affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
          teaser: '',
          content: '',
          contextSnippet: '',
        },
        {
          id: 'final',
          title: '最终底牌',
          affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
          teaser: '',
          content: '',
          contextSnippet: '',
        },
      ],
    },
    skills: [
      { id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
      { id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
      { id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
    ],
    initialItems: [
      {
        id: 'item-1',
        name: '随身武具',
        category: '武器',
        quantity: 1,
        rarity: 'rare',
        description: '',
        tags: ['自定义'],
      },
      {
        id: 'item-2',
        name: '补给包',
        category: '消耗品',
        quantity: 2,
        rarity: 'uncommon',
        description: '',
        tags: ['自定义'],
      },
      {
        id: 'item-3',
        name: '私人物件',
        category: '专属物品',
        quantity: 1,
        rarity: 'rare',
        description: '',
        tags: ['自定义'],
      },
    ],
    templateCharacterId: template?.id,
  };
}

function createStoryNpc(
  profile: Pick<CustomWorldProfile, 'storyNpcs'>,
): CustomWorldNpc {
  const seed = Date.now() + profile.storyNpcs.length;
  const npc = {
    id: createEntryId(
      'story-npc',
      `场景角色-${profile.storyNpcs.length + 1}`,
      seed,
    ),
    name: `自定义场景角色${profile.storyNpcs.length + 1}`,
    title: '自定义头衔',
    role: '自定义身份',
    description: '',
    backstory: '',
    personality: '',
    motivation: '',
    combatStyle: '',
    initialAffinity: 6,
    relationshipHooks: ['合作', '互动'],
    relations: [],
    tags: ['自定义'],
    backstoryReveal: {
      publicSummary: '',
      chapters: [
        {
          id: 'surface',
          title: '表层来意',
          affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
          teaser: '',
          content: '',
          contextSnippet: '',
        },
        {
          id: 'scar',
          title: '旧事裂痕',
          affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
          teaser: '',
          content: '',
          contextSnippet: '',
        },
        {
          id: 'hidden',
          title: '隐藏执念',
          affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
          teaser: '',
          content: '',
          contextSnippet: '',
        },
        {
          id: 'final',
          title: '最终底牌',
          affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
          teaser: '',
          content: '',
          contextSnippet: '',
        },
      ],
    },
    skills: [
      { id: 'skill-1', name: '基础起手', summary: '', style: '起手压制' },
      { id: 'skill-2', name: '常用变招', summary: '', style: '机动周旋' },
      { id: 'skill-3', name: '压箱底牌', summary: '', style: '爆发终结' },
    ],
    initialItems: [
      {
        id: 'item-1',
        name: '随身武具',
        category: '武器',
        quantity: 1,
        rarity: 'rare',
        description: '',
        tags: ['自定义'],
      },
      {
        id: 'item-2',
        name: '补给包',
        category: '消耗品',
        quantity: 2,
        rarity: 'uncommon',
        description: '',
        tags: ['自定义'],
      },
      {
        id: 'item-3',
        name: '私人物件',
        category: '专属物品',
        quantity: 1,
        rarity: 'rare',
        description: '',
        tags: ['自定义'],
      },
    ],
  } satisfies CustomWorldNpc;

  return npc;
}

function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
  const seed = Date.now() + profile.landmarks.length;
  const previousLandmark = profile.landmarks[profile.landmarks.length - 1];
  return {
    id: createEntryId(
      'landmark',
      `scene-${profile.landmarks.length + 1}`,
      seed,
    ),
    name: `自定义场景${profile.landmarks.length + 1}`,
    description: '',
    dangerLevel: '中',
    imageSrc: undefined,
    sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
    connections: previousLandmark
      ? [
          {
            targetLandmarkId: previousLandmark.id,
            relativePosition: 'south',
            summary: `南侧可回到${previousLandmark.name}`,
          },
        ]
      : [],
  };
}

export function CustomWorldEntityEditorModal({
  profile,
  target,
  onClose,
  onProfileChange,
}: CustomWorldEntityEditorModalProps) {
  if (!target) return null;

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

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

  if (target.kind === 'playable') {
    if (target.mode === 'create') {
      return (
        <PlayableNpcEditor
          profile={profile}
          npc={createPlayableNpc(profile)}
          mode="create"
          onSave={(nextNpc) =>
            onProfileChange({
              ...profile,
              playableNpcs: [...profile.playableNpcs, nextNpc],
            })
          }
          onClose={onClose}
        />
      );
    }

    const npc = profile.playableNpcs.find((item) => item.id === target.id);
    return npc ? (
      <PlayableNpcEditor
        profile={profile}
        npc={npc}
        mode="edit"
        onSave={(nextNpc) =>
          onProfileChange({
            ...profile,
            playableNpcs: profile.playableNpcs.map((item) =>
              item.id === nextNpc.id ? nextNpc : item,
            ),
          })
        }
        onClose={onClose}
      />
    ) : null;
  }

  if (target.kind === 'story') {
    if (target.mode === 'create') {
      return (
        <StoryNpcEditor
          profile={profile}
          npc={createStoryNpc(profile)}
          mode="create"
          onSave={(nextNpc) =>
            onProfileChange({
              ...profile,
              storyNpcs: [...profile.storyNpcs, nextNpc],
            })
          }
          onClose={onClose}
        />
      );
    }

    const npc = profile.storyNpcs.find((item) => item.id === target.id);
    return npc ? (
      <StoryNpcEditor
        profile={profile}
        npc={npc}
        mode="edit"
        onSave={(nextNpc) =>
          onProfileChange({
            ...profile,
            storyNpcs: profile.storyNpcs.map((item) =>
              item.id === nextNpc.id ? nextNpc : item,
            ),
          })
        }
        onClose={onClose}
      />
    ) : null;
  }

  if (target.mode === 'create') {
    return (
      <LandmarkEditor
        profile={profile}
        landmark={createLandmark(profile)}
        mode="create"
        onSaveProfile={onProfileChange}
        onClose={onClose}
      />
    );
  }

  const landmark = profile.landmarks.find((entry) => entry.id === target.id);
  return landmark ? (
    <LandmarkEditor
      profile={profile}
      landmark={landmark}
      mode="edit"
      onSaveProfile={onProfileChange}
      onClose={onClose}
    />
  ) : null;
}
