﻿import {
  ArrowLeft,
  CheckCircle2,
  Eye,
  ImageIcon,
  ImagePlus,
  Loader2,
  Music,
  Play,
  Plus,
  Send,
  Trash2,
  Wand2,
  X,
} from 'lucide-react';
import {
  type ChangeEvent,
  type ReactNode,
  useEffect,
  useMemo,
  useState,
} from 'react';
import { createPortal } from 'react-dom';

import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent';
import type {
  Match3DGeneratedItemAsset,
  Match3DWorkProfile,
  PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import {
  createBackgroundMusicTask,
  createSoundEffectTask,
  publishBackgroundMusicAsset,
  publishSoundEffectAsset,
  waitForGeneratedAudioAsset,
} from '../../services/creation-audio';
import {
  generateMatch3DBackgroundImage,
  generateMatch3DCoverImage,
  generateMatch3DItemAssets,
  generateMatch3DWorkTags,
  publishMatch3DWork,
  updateMatch3DGeneratedItemAssets,
  updateMatch3DWork,
} from '../../services/match3d-works';
import {
  getMatch3DGeneratedImageViewSources,
  resolveMatch3DGeneratedImageAssetSource,
  resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
import { useAuthUi } from '../auth/AuthUiContext';
import {
  MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
  MATCH3D_RUNTIME_GLASS_SPINNER_CLASS,
  MATCH3D_RUNTIME_GLASS_TIMER_CLASS,
  MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
  MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
} from '../match3d-runtime/match3dRuntimeUiStyles';
import { ResolvedAssetImage } from '../ResolvedAssetImage';

type Match3DResultViewProps = {
  profile: Match3DWorkProfile;
  draft?: Match3DResultDraft | null;
  isBusy?: boolean;
  error?: string | null;
  onBack: () => void;
  onSaved?: (profile: Match3DWorkProfile) => void;
  onPublished?: (profile: Match3DWorkProfile) => void;
  onStartTestRun: (
    profile: Match3DWorkProfile,
    options?: { itemTypeCountOverride?: number },
  ) => void;
};

type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type Match3DResultTab = 'work' | 'config' | 'assets';
type Match3DAssetConfigTab = 'items' | 'ui' | 'music';
type Match3DAssetTaskStatus =
  | 'idle'
  | 'submitting'
  | 'waiting'
  | 'generating'
  | 'image_ready'
  | 'done'
  | 'failed'
  | 'unknown';

type Match3DBatchItemGenerationState = {
  phase: 'idle' | 'generating' | 'done' | 'failed';
  progress: number | null;
  itemNames: string[];
  message: string | null;
  error: string | null;
};

type Match3DItemAssetDraft = {
  id: string;
  name: string;
  usage: string;
  prompt: string;
  referenceImageSrc: string;
  imageViews: Match3DGeneratedItemAsset['imageViews'];
  taskUuid: string | null;
  subscriptionKey: string | null;
  status: Match3DAssetTaskStatus;
  progress: number | null;
  downloads: Array<{ name: string; url: string }>;
  soundPrompt: string;
  backgroundMusicTitle: string | null;
  backgroundMusicStyle: string | null;
  backgroundMusicPrompt: string | null;
  backgroundMusic: CreationAudioAsset | null;
  clickSound: CreationAudioAsset | null;
  backgroundAsset: Match3DGeneratedItemAsset['backgroundAsset'] | null;
  error: string | null;
  updatedAt: string | null;
};

type Match3DResultEditState = {
  gameName: string;
  summary: string;
  tagsText: string;
  coverImageSrc: string;
  themeText: string;
  clearCountText: string;
  difficultyText: string;
};

type Match3DCoverSourceAsset = {
  id: string;
  label: string;
  imageSrc: string;
  kind: 'item' | 'ui';
};

const MATCH3D_MIN_TAG_COUNT = 3;
const MATCH3D_MAX_TAG_COUNT = 6;
const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
const MATCH3D_DEFAULT_ASSET_COUNT = 6;
const MATCH3D_BACKGROUND_MUSIC_ASSET_KIND = 'match3d_background_music';
const MATCH3D_CLICK_SOUND_ASSET_KIND = 'match3d_click_sound';
const MATCH3D_BACKGROUND_MUSIC_POINTS_COST = 5;
const MATCH3D_CLICK_SOUND_POINTS_COST = 10;
const MATCH3D_UI_BACKGROUND_POINTS_COST = 2;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH = 2;
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 5;

const MATCH3D_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [
  { id: 'work', label: '作品信息' },
  { id: 'config', label: '难度配置' },
  { id: 'assets', label: '素材配置' },
];

const MATCH3D_ASSET_CONFIG_TABS: Array<{
  id: Match3DAssetConfigTab;
  label: string;
}> = [
  { id: 'items', label: '物品' },
  { id: 'ui', label: 'UI' },
  { id: 'music', label: '背景音乐' },
];

// 中文注释：结果页难度配置必须与创作入口页保持同一组派生参数。
const MATCH3D_DIFFICULTY_OPTIONS = [
  { id: 'easy', label: '轻松', clearCount: 8, difficulty: 2, itemTypeCount: 3 },
  {
    id: 'standard',
    label: '标准',
    clearCount: 12,
    difficulty: 4,
    itemTypeCount: 9,
  },
  {
    id: 'advanced',
    label: '进阶',
    clearCount: 16,
    difficulty: 6,
    itemTypeCount: 15,
  },
  {
    id: 'hardcore',
    label: '硬核',
    clearCount: 21,
    difficulty: 8,
    itemTypeCount: 21,
  },
] as const;

type Match3DDifficultyOptionId =
  (typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
type Match3DDifficultyOption = (typeof MATCH3D_DIFFICULTY_OPTIONS)[number];

const MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC =
  '/match3d-background-references/pot-fused-reference.png';

const MATCH3D_MATERIAL_TAB_BUTTON_CLASS =
  'min-h-10 rounded-[0.9rem] px-3 text-sm font-bold transition';

function resolveMatch3DDifficultyOptionId(
  difficulty: number | null | undefined,
  clearCount: number | null | undefined,
): Match3DDifficultyOptionId {
  const byClearCount = MATCH3D_DIFFICULTY_OPTIONS.find(
    (option) => option.clearCount === clearCount,
  );
  if (byClearCount) {
    return byClearCount.id;
  }

  if (typeof difficulty !== 'number' || !Number.isFinite(difficulty)) {
    return 'standard';
  }

  const normalizedDifficulty = Math.max(
    1,
    Math.min(10, Math.round(difficulty)),
  );
  return MATCH3D_DIFFICULTY_OPTIONS.reduce(
    (nearestOption, option) =>
      Math.abs(option.difficulty - normalizedDifficulty) <
      Math.abs(nearestOption.difficulty - normalizedDifficulty)
        ? option
        : nearestOption,
    MATCH3D_DIFFICULTY_OPTIONS[1],
  ).id;
}

function getMatch3DDifficultyOption(optionId: Match3DDifficultyOptionId) {
  return (
    MATCH3D_DIFFICULTY_OPTIONS.find((option) => option.id === optionId) ??
    MATCH3D_DIFFICULTY_OPTIONS[1]
  );
}

function getMatch3DReadyItemTypeCount(
  generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
  return generatedItemAssets.filter(hasMatch3DGeneratedFiveViewImageSource)
    .length;
}

function getMatch3DPlayableItemTypeCount(
  targetItemTypeCount: number,
  generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
  return Math.max(
    1,
    Math.min(
      targetItemTypeCount,
      getMatch3DReadyItemTypeCount(generatedItemAssets),
    ),
  );
}

function getMatch3DDifficultyOptionFromEditState(
  editState: Match3DResultEditState,
) {
  return getMatch3DDifficultyOption(
    resolveMatch3DDifficultyOptionId(
      normalizeDifficulty(editState.difficultyText),
      normalizePositiveInteger(editState.clearCountText),
    ),
  );
}

function resolveMatch3DBackgroundPreviewSource(
  profile: Match3DWorkProfile,
  draft: Match3DResultDraft | null,
  generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
  return (
    draft?.backgroundImageSrc?.trim() ||
    draft?.generatedBackgroundAsset?.imageSrc?.trim() ||
    draft?.backgroundImageObjectKey?.trim() ||
    draft?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
    profile.backgroundImageSrc?.trim() ||
    profile.generatedBackgroundAsset?.imageSrc?.trim() ||
    profile.backgroundImageObjectKey?.trim() ||
    profile.generatedBackgroundAsset?.imageObjectKey?.trim() ||
    generatedItemAssets
      .map(
        (asset) =>
          asset.backgroundAsset?.imageSrc?.trim() ||
          asset.backgroundAsset?.imageObjectKey?.trim() ||
          '',
      )
      .find(Boolean) ||
    ''
  );
}

function resolveMatch3DBackgroundPrompt(
  profile: Match3DWorkProfile,
  draft: Match3DResultDraft | null,
  generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
  return (
    draft?.backgroundPrompt?.trim() ||
    draft?.generatedBackgroundAsset?.prompt?.trim() ||
    profile.backgroundPrompt?.trim() ||
    profile.generatedBackgroundAsset?.prompt?.trim() ||
    generatedItemAssets
      .map((asset) => asset.backgroundAsset?.prompt?.trim() || '')
      .find(Boolean) ||
    buildFallbackMatch3DBackgroundPrompt(profile.themeText)
  );
}

function resolveMatch3DContainerPreviewSource(
  generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
  return (
    generatedItemAssets
      .map(
        (asset) =>
          asset.backgroundAsset?.containerImageSrc?.trim() ||
          asset.backgroundAsset?.containerImageObjectKey?.trim() ||
          '',
      )
      .find(Boolean) || ''
  );
}

function buildFallbackMatch3DBackgroundPrompt(themeText: string) {
  const theme = themeText.trim() || '抓大鹅';
  return `${theme}题材抓大鹅游戏竖屏纯背景图，表现题材环境、绿色纵向渐变和轻快休闲氛围，中央区域保持干净通透，无锅、无圆盘、无托盘、无拼图槽、无物品槽、无文字、无水印、无 UI、无按钮、无倒计时、无物品。`;
}

function normalizeTags(value: string) {
  return [
    ...new Set(
      value
        .split(/[\n,，、]/u)
        .map((entry) => normalizeMatch3DTag(entry))
        .filter(Boolean),
    ),
  ];
}

function buildMatch3DAssetPrompt(
  profile: Match3DWorkProfile,
  assetName: string,
  usage: string,
) {
  return buildMatch3DAssetPromptFromDraft(profile.themeText, assetName, usage);
}

function buildMatch3DAssetPromptFromDraft(
  themeText: string,
  assetName: string,
  usage: string,
) {
  const normalizedTheme = themeText.trim() || '主题';
  return [
    `${normalizedTheme}题材抓大鹅游戏内2D素材：${assetName}`,
    usage,
    '适合移动端游戏直接显示，主体清晰，五视角一致，干净背景。',
  ]
    .filter(Boolean)
    .join('，');
}

function buildFallbackMatch3DClickSoundPrompt(
  profile: Pick<Match3DWorkProfile, 'themeText'>,
  assetName: string,
) {
  const normalizedTheme = profile.themeText.trim() || '抓大鹅';
  const normalizedName = assetName.trim() || '物品';
  return `${normalizedTheme}题材抓大鹅中“${normalizedName}”被点击并消除时的短促反馈音效，清脆、可爱、有轻微弹跳感，适合移动端休闲游戏。`;
}

function normalizeMatch3DTag(value: string) {
  return value
    .trim()
    .replace(/^[\s#"'`，。、“”《》；：！？,.、]+/u, '')
    .replace(/[\s#"'`，。、“”《》；：！？,.、]+$/u, '')
    .slice(0, 12);
}

function normalizeMatch3DTagListText(value: string) {
  return [
    ...new Set(
      value
        .split(/[\n,，、]/u)
        .map((entry) => normalizeMatch3DTag(entry))
        .filter(Boolean),
    ),
  ];
}

function normalizeMatch3DItemName(value: string) {
  return value
    .trim()
    .replace(/^[-*•\d.、)\s]+/u, '')
    .slice(0, 12);
}

function normalizeMatch3DItemNameList(values: readonly string[]) {
  return [...new Set(values.map(normalizeMatch3DItemName).filter(Boolean))];
}

function calculateMatch3DItemAssetsPointsCost(itemCount: number) {
  if (itemCount <= 0) {
    return 0;
  }
  return (
    Math.ceil(itemCount / MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE) *
    MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH
  );
}

function hasMatch3DGeneratedModelSource(asset: Match3DGeneratedItemAsset) {
  return Boolean(asset.modelSrc?.trim() || asset.modelObjectKey?.trim());
}

function hasMatch3DGeneratedImageSource(asset: Match3DGeneratedItemAsset) {
  return getMatch3DGeneratedImageViewSources(asset).length > 0;
}

function hasMatch3DGeneratedFiveViewImageSource(
  asset: Match3DGeneratedItemAsset,
) {
  return (
    (asset.imageViews ?? []).filter(
      (view) =>
        Boolean(view.imageSrc?.trim()) || Boolean(view.imageObjectKey?.trim()),
    ).length >= 5
  );
}

function resolveMatch3DGeneratedImageViewSourceFromDraft(
  view: NonNullable<Match3DGeneratedItemAsset['imageViews']>[number],
) {
  return view.imageObjectKey?.trim() || view.imageSrc?.trim() || '';
}

function resolveMatch3DAssetDraftImageViewSources(
  asset: Match3DItemAssetDraft,
) {
  return [
    ...new Set(
      (asset.imageViews ?? [])
        .map(resolveMatch3DGeneratedImageViewSourceFromDraft)
        .filter(Boolean),
    ),
  ];
}

function resolveMatch3DAssetDraftPreviewSources(asset: Match3DItemAssetDraft) {
  const imageViewSources = resolveMatch3DAssetDraftImageViewSources(asset);
  if (imageViewSources.length > 0) {
    return imageViewSources.slice(0, 5);
  }
  const fallbackSource = asset.referenceImageSrc.trim();
  return fallbackSource ? [fallbackSource] : [];
}

function hasPersistableMatch3DGeneratedItemAsset(
  asset: Match3DGeneratedItemAsset,
) {
  return Boolean(
    asset.imageSrc?.trim() ||
      asset.imageObjectKey?.trim() ||
      getMatch3DGeneratedImageViewSources(asset).length > 0 ||
      asset.modelSrc?.trim() ||
      asset.modelObjectKey?.trim() ||
      asset.taskUuid?.trim() ||
      asset.subscriptionKey?.trim() ||
      asset.backgroundAsset?.imageSrc?.trim() ||
      asset.backgroundAsset?.imageObjectKey?.trim() ||
      asset.backgroundAsset?.containerImageSrc?.trim() ||
      asset.backgroundAsset?.containerImageObjectKey?.trim() ||
      asset.backgroundAsset?.prompt?.trim() ||
      asset.backgroundMusic ||
      asset.clickSound,
  );
}

function getMatch3DGeneratedItemAssetPersistenceSignature(
  asset: Match3DGeneratedItemAsset,
) {
  return [
    asset.itemId.trim(),
    asset.itemName.trim(),
    asset.imageSrc?.trim() ?? '',
    asset.imageObjectKey?.trim() ?? '',
    ...(asset.imageViews ?? []).flatMap((view) => [
      view.viewId.trim(),
      String(view.viewIndex),
      view.imageSrc?.trim() ?? '',
      view.imageObjectKey?.trim() ?? '',
    ]),
    asset.modelSrc?.trim() ?? '',
    asset.modelObjectKey?.trim() ?? '',
    asset.modelFileName?.trim() ?? '',
    asset.taskUuid?.trim() ?? '',
    asset.subscriptionKey?.trim() ?? '',
    asset.status.trim(),
    asset.soundPrompt?.trim() ?? '',
    asset.backgroundMusicTitle?.trim() ?? '',
    asset.backgroundMusicStyle?.trim() ?? '',
    asset.backgroundMusicPrompt?.trim() ?? '',
    asset.backgroundMusic?.audioSrc?.trim() ??
      asset.backgroundMusic?.assetObjectId?.trim() ??
      asset.backgroundMusic?.taskId?.trim() ??
      '',
    asset.backgroundAsset?.prompt?.trim() ?? '',
    asset.backgroundAsset?.imageSrc?.trim() ?? '',
    asset.backgroundAsset?.imageObjectKey?.trim() ?? '',
    asset.backgroundAsset?.containerPrompt?.trim() ?? '',
    asset.backgroundAsset?.containerImageSrc?.trim() ?? '',
    asset.backgroundAsset?.containerImageObjectKey?.trim() ?? '',
    asset.backgroundAsset?.status?.trim() ?? '',
    asset.backgroundAsset?.error?.trim() ?? '',
    asset.clickSound?.audioSrc?.trim() ??
      asset.clickSound?.assetObjectId?.trim() ??
      asset.clickSound?.taskId?.trim() ??
      '',
    asset.error?.trim() ?? '',
  ].join('\u001f');
}

function shouldPersistGeneratedItemAssets(
  currentAssets: readonly Match3DGeneratedItemAsset[],
  savedAssets: readonly Match3DGeneratedItemAsset[] = [],
) {
  if (currentAssets.length <= 0) {
    return false;
  }
  if (currentAssets.length !== savedAssets.length) {
    return true;
  }

  return currentAssets.some((asset, index) => {
    const savedAsset = savedAssets[index];
    return (
      !savedAsset ||
      getMatch3DGeneratedItemAssetPersistenceSignature(asset) !==
        getMatch3DGeneratedItemAssetPersistenceSignature(savedAsset)
    );
  });
}

function mergeMatch3DGeneratedItemAsset(
  base: Match3DGeneratedItemAsset,
  override: Match3DGeneratedItemAsset,
): Match3DGeneratedItemAsset {
  const overrideHasModel = hasMatch3DGeneratedModelSource(override);
  const overrideHasImages = hasMatch3DGeneratedImageSource(override);
  return {
    ...base,
    itemName: override.itemName.trim() || base.itemName,
    imageSrc: override.imageSrc?.trim()
      ? override.imageSrc
      : (base.imageSrc ?? null),
    imageObjectKey: override.imageObjectKey?.trim()
      ? override.imageObjectKey
      : (base.imageObjectKey ?? null),
    imageViews:
      override.imageViews && override.imageViews.length > 0
        ? override.imageViews
        : (base.imageViews ?? []),
    modelSrc: override.modelSrc?.trim()
      ? override.modelSrc
      : (base.modelSrc ?? null),
    modelObjectKey: override.modelObjectKey?.trim()
      ? override.modelObjectKey
      : (base.modelObjectKey ?? null),
    modelFileName: override.modelFileName?.trim()
      ? override.modelFileName
      : (base.modelFileName ?? null),
    taskUuid: override.taskUuid?.trim()
      ? override.taskUuid
      : (base.taskUuid ?? null),
    subscriptionKey: override.subscriptionKey?.trim()
      ? override.subscriptionKey
      : (base.subscriptionKey ?? null),
    backgroundMusic: override.backgroundMusic ?? base.backgroundMusic ?? null,
    clickSound: override.clickSound ?? base.clickSound ?? null,
    backgroundAsset: override.backgroundAsset ?? base.backgroundAsset ?? null,
    soundPrompt: override.soundPrompt?.trim()
      ? override.soundPrompt
      : (base.soundPrompt ?? null),
    backgroundMusicTitle: override.backgroundMusicTitle?.trim()
      ? override.backgroundMusicTitle
      : (base.backgroundMusicTitle ?? null),
    backgroundMusicStyle: override.backgroundMusicStyle?.trim()
      ? override.backgroundMusicStyle
      : (base.backgroundMusicStyle ?? null),
    backgroundMusicPrompt: override.backgroundMusicPrompt?.trim()
      ? override.backgroundMusicPrompt
      : (base.backgroundMusicPrompt ?? null),
    // 中文注释：新草稿以 2D 多视角图片为正式素材；历史模型字段只做兼容保留。
    status:
      overrideHasModel && base.status !== 'model_ready'
        ? 'model_ready'
        : overrideHasImages
          ? 'image_ready'
        : base.status,
    error: override.error ?? base.error ?? null,
  };
}

function createMatch3DAssetDrafts(
  profile: Match3DWorkProfile,
  draft: Match3DResultDraft | null = null,
): Match3DItemAssetDraft[] {
  const generatedAssets = resolveMatch3DResultGeneratedItemAssets(
    profile,
    draft,
  );
  if (generatedAssets?.length) {
    return generatedAssets.map((asset) =>
      createMatch3DAssetDraftFromGeneratedAsset(profile, asset),
    );
  }

  const theme = profile.themeText.trim() || '主题';
  const seeds = [
    {
      id: 'primary-item',
      name: `${theme}核心物件`,
      usage: '局内主要点击消除物件',
    },
    {
      id: 'rare-item',
      name: `${theme}稀有物件`,
      usage: '用于增强辨识度的高价值物件',
    },
    {
      id: 'bonus-item',
      name: `${theme}奖励物件`,
      usage: '通关或连消反馈奖励',
    },
    {
      id: 'obstacle-item',
      name: `${theme}干扰物件`,
      usage: '堆叠层中的视觉遮挡物',
    },
    {
      id: 'tray-prop',
      name: `${theme}托盘道具`,
      usage: '备选栏和结算展示道具',
    },
    {
      id: 'scene-prop',
      name: `${theme}场景小物`,
      usage: '圆形空间周边装饰物',
    },
  ].slice(0, MATCH3D_DEFAULT_ASSET_COUNT);

  return seeds.map((seed) => ({
    ...seed,
    prompt: buildMatch3DAssetPrompt(profile, seed.name, seed.usage),
    referenceImageSrc: profile.referenceImageSrc ?? profile.coverImageSrc ?? '',
    imageViews: [],
    taskUuid: null,
    subscriptionKey: null,
    status: 'idle',
    progress: null,
    downloads: [],
    soundPrompt: buildFallbackMatch3DClickSoundPrompt(profile, seed.name),
    backgroundMusicTitle: null,
    backgroundMusicStyle: null,
    backgroundMusicPrompt: null,
    backgroundMusic: null,
    clickSound: null,
    backgroundAsset: null,
    error: null,
    updatedAt: null,
  }));
}

function createMatch3DAssetDraftFromGeneratedAsset(
  profile: Match3DWorkProfile,
  asset: Match3DGeneratedItemAsset,
): Match3DItemAssetDraft {
  const modelSource = resolveMatch3DGeneratedModelAssetSource(asset);
  const imageSource = resolveMatch3DGeneratedImageAssetSource(asset);
  const downloads = modelSource
    ? [
        {
          name: asset.modelFileName ?? `${asset.itemName}.glb`,
          url: modelSource,
        },
      ]
    : [];

  return {
    id: asset.itemId,
    name: asset.itemName,
    usage: '局内点击消除物件',
    prompt: buildMatch3DAssetPrompt(
      profile,
      asset.itemName,
      '局内点击消除物件',
    ),
    referenceImageSrc:
      imageSource ||
      asset.imageSrc ||
      profile.referenceImageSrc ||
      profile.coverImageSrc ||
      '',
    imageViews: asset.imageViews ?? [],
    taskUuid: asset.taskUuid ?? null,
    subscriptionKey: asset.subscriptionKey ?? null,
    status:
      asset.status === 'model_ready'
        ? 'done'
        : normalizeMatch3DAssetStatus(asset.status),
    progress: asset.status === 'model_ready' ? 1 : null,
    downloads,
    soundPrompt:
      asset.soundPrompt?.trim() ||
      asset.clickSound?.prompt?.trim() ||
      buildFallbackMatch3DClickSoundPrompt(profile, asset.itemName),
    backgroundMusicTitle:
      asset.backgroundMusicTitle ?? asset.backgroundMusic?.title ?? null,
    backgroundMusicStyle: asset.backgroundMusicStyle ?? null,
    backgroundMusicPrompt:
      asset.backgroundMusicPrompt ?? asset.backgroundMusic?.prompt ?? null,
    backgroundMusic: asset.backgroundMusic ?? null,
    backgroundAsset: asset.backgroundAsset ?? null,
    clickSound: asset.clickSound ?? null,
    error: asset.error ?? null,
    updatedAt: profile.updatedAt,
  };
}

function createGeneratedAssetsFromDrafts(
  assetDrafts: Match3DItemAssetDraft[],
  existingAssets: readonly Match3DGeneratedItemAsset[] = [],
): Match3DGeneratedItemAsset[] {
  const existingById = new Map(
    existingAssets.map((asset) => [asset.itemId, asset]),
  );
  return assetDrafts.map((asset) => {
    const existing = existingById.get(asset.id);
    const modelFile = asset.downloads.find((file) => file.url.trim()) ?? null;
    const modelSource =
      modelFile?.url.trim() ||
      existing?.modelSrc?.trim() ||
      existing?.modelObjectKey?.trim() ||
      null;
    const modelObjectKey =
      modelFile?.url && isGeneratedLegacyPath(modelFile.url)
        ? modelFile.url.trim().replace(/^\/+/u, '')
        : (existing?.modelObjectKey ?? null);
    return {
      itemId: asset.id,
      itemName: asset.name,
      imageSrc: existing?.imageSrc ?? (asset.referenceImageSrc || null),
      imageObjectKey: existing?.imageObjectKey ?? null,
      imageViews: asset.imageViews ?? existing?.imageViews ?? [],
      modelSrc: modelSource,
      modelObjectKey,
      modelFileName: modelFile?.name?.trim() || existing?.modelFileName || null,
      taskUuid: asset.taskUuid,
      subscriptionKey: asset.subscriptionKey,
      soundPrompt: asset.soundPrompt.trim() || existing?.soundPrompt || null,
      backgroundMusicTitle:
        asset.backgroundMusicTitle ?? existing?.backgroundMusicTitle ?? null,
      backgroundMusicStyle:
        asset.backgroundMusicStyle ?? existing?.backgroundMusicStyle ?? null,
      backgroundMusicPrompt:
        asset.backgroundMusicPrompt ?? existing?.backgroundMusicPrompt ?? null,
      backgroundMusic: asset.backgroundMusic,
      clickSound: asset.clickSound,
      backgroundAsset:
        asset.backgroundAsset ??
        existing?.backgroundAsset ??
        (asset.id === assetDrafts[0]?.id
          ? (existingAssets
              .map((candidate) => candidate.backgroundAsset ?? null)
              .find(Boolean) ?? null)
          : null),
      // 中文注释：当前主链路只要求 2D 图片素材；但历史草稿若已有平台模型字段，保存时不能把模型状态降回图片态。
      status: modelSource?.trim()
        ? 'model_ready'
        : hasMatch3DGeneratedImageSource(
              existing ?? ({} as Match3DGeneratedItemAsset),
            )
          ? 'image_ready'
          : asset.status === 'done'
            ? 'model_ready'
            : asset.status,
      error: asset.error,
    };
  });
}

function normalizeMatch3DAssetStatus(status: string): Match3DAssetTaskStatus {
  const normalized = status.trim().toLowerCase();
  if (
    normalized === 'waiting' ||
    normalized === 'pending' ||
    normalized === 'queued'
  ) {
    return 'waiting';
  }
  if (
    normalized === 'generating' ||
    normalized === 'running' ||
    normalized === 'processing'
  ) {
    return 'generating';
  }
  if (normalized === 'image_ready') {
    return 'image_ready';
  }
  if (
    normalized === 'done' ||
    normalized === 'succeeded' ||
    normalized === 'success' ||
    normalized === 'completed'
  ) {
    return 'done';
  }
  if (
    normalized === 'failed' ||
    normalized === 'error' ||
    normalized === 'canceled' ||
    normalized === 'cancelled'
  ) {
    return 'failed';
  }

  return 'unknown';
}

function Match3DAudioProgress({
  label,
  progress,
}: {
  label: string;
  progress: number;
}) {
  const normalizedProgress = Math.max(0, Math.min(1, progress));
  return (
    <div className="mt-3" role="status" aria-label={label}>
      <div className="mb-1 flex items-center justify-between text-[11px] font-semibold text-[var(--platform-text-soft)]">
        <span>{label}</span>
        <span>{Math.round(normalizedProgress * 100)}%</span>
      </div>
      <div className="h-2 overflow-hidden rounded-full bg-white/70">
        <div
          className="h-full rounded-full bg-[var(--platform-accent)] transition-[width] duration-300"
          style={{ width: `${Math.round(normalizedProgress * 100)}%` }}
        />
      </div>
    </div>
  );
}

function Match3DResolvedAudio({
  ariaLabel,
  src,
}: {
  ariaLabel?: string;
  src: string;
}) {
  const { resolvedUrl } = useResolvedAssetReadUrl(src, {
    expireSeconds: 300,
  });

  if (!resolvedUrl) {
    return (
      <div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
        音频已绑定
      </div>
    );
  }

  return (
    <audio
      className="mt-3 w-full"
      controls
      src={resolvedUrl}
      aria-label={ariaLabel}
    />
  );
}

function getMatch3DBatchGenerationStatusLabel(
  phase: Match3DBatchItemGenerationState['phase'],
) {
  if (phase === 'done') return '生成完成';
  if (phase === 'failed') return '生成失败';
  if (phase === 'generating') return '生成中';
  return '';
}

function Match3DBatchGenerationProgress({
  generationState,
}: {
  generationState: Match3DBatchItemGenerationState;
}) {
  if (generationState.phase === 'idle') {
    return null;
  }

  const normalizedProgress =
    generationState.progress === null
      ? null
      : Math.max(0, Math.min(1, generationState.progress));

  return (
    <div
      className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3"
      role="status"
      aria-label="物品素材生成进度"
    >
      <div className="mb-2 flex items-center justify-between text-xs font-bold text-[var(--platform-text-base)]">
        <span>
          {getMatch3DBatchGenerationStatusLabel(generationState.phase)}
        </span>
        {normalizedProgress !== null ? (
          <span>{Math.round(normalizedProgress * 100)}%</span>
        ) : null}
      </div>
      {normalizedProgress !== null ? (
        <div className="h-2 overflow-hidden rounded-full bg-white/70">
          <div
            className="h-full rounded-full bg-[var(--platform-accent)] transition-[width] duration-300"
            style={{ width: `${Math.round(normalizedProgress * 100)}%` }}
          />
        </div>
      ) : null}
      {generationState.message ? (
        <div className="mt-2 text-sm font-semibold text-[var(--platform-text-base)]">
          {generationState.message}
        </div>
      ) : null}
      {generationState.error ? (
        <div className="mt-2 text-sm font-semibold text-rose-600">
          {generationState.error}
        </div>
      ) : null}
    </div>
  );
}

function normalizePositiveInteger(value: string) {
  const parsed = Number.parseInt(value.trim(), 10);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}

function normalizeDifficulty(value: string) {
  const parsed = Number.parseInt(value.trim(), 10);
  return Number.isFinite(parsed) && parsed >= 1 && parsed <= 10 ? parsed : null;
}

function createEditState(profile: Match3DWorkProfile): Match3DResultEditState {
  const difficultyOption = getMatch3DDifficultyOption(
    resolveMatch3DDifficultyOptionId(profile.difficulty, profile.clearCount),
  );
  return {
    gameName: profile.gameName,
    summary: profile.summary,
    tagsText: profile.tags.join('，'),
    coverImageSrc:
      profile.coverImageSrc?.trim() || profile.referenceImageSrc?.trim() || '',
    themeText: profile.themeText,
    clearCountText: String(difficultyOption.clearCount),
    difficultyText: String(difficultyOption.difficulty),
  };
}

function buildSavePayload(
  editState: Match3DResultEditState,
): PutMatch3DWorkRequest | null {
  const clearCount = normalizePositiveInteger(editState.clearCountText);
  const difficulty = normalizeDifficulty(editState.difficultyText);
  const gameName = editState.gameName.trim();
  const themeText = editState.themeText.trim();
  const summary = editState.summary.trim();
  const tags = normalizeTags(editState.tagsText);

  if (!gameName || !themeText || !clearCount || !difficulty) {
    return null;
  }

  return {
    gameName,
    themeText,
    summary,
    tags,
    coverImageSrc: editState.coverImageSrc.trim() || null,
    clearCount,
    difficulty,
  };
}

function buildPublishBlockers(
  editState: Match3DResultEditState,
  generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
  const tags = normalizeTags(editState.tagsText);
  const selectedDifficulty = getMatch3DDifficultyOptionFromEditState(editState);
  const readyItemTypeCount = getMatch3DReadyItemTypeCount(generatedItemAssets);
  const assetReadyBlocker =
    generatedItemAssets.length > 0 &&
    readyItemTypeCount < selectedDifficulty.itemTypeCount
      ? [
          `当前难度需要 ${selectedDifficulty.itemTypeCount} 种物品，已生成 ${readyItemTypeCount} 种，请先在素材配置中补齐。`,
        ]
      : [];
  const blockers = [
    ...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
    ...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
    ...(editState.summary.trim() ? [] : ['简介不能为空。']),
    ...(editState.coverImageSrc.trim() ? [] : ['封面图不能为空。']),
    ...(tags.length >= MATCH3D_MIN_TAG_COUNT &&
    tags.length <= MATCH3D_MAX_TAG_COUNT
      ? []
      : [
          `标签数量需要在 ${MATCH3D_MIN_TAG_COUNT} 到 ${MATCH3D_MAX_TAG_COUNT} 个之间。`,
        ]),
    ...(normalizePositiveInteger(editState.clearCountText)
      ? []
      : ['需要消除次数必须为正整数。']),
    ...(normalizeDifficulty(editState.difficultyText)
      ? []
      : ['难度必须为 1 到 10。']),
    ...assetReadyBlocker,
  ];

  return [...new Set(blockers)];
}

function buildTestRunBlockers(editState: Match3DResultEditState) {
  const blockers = [
    ...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
    ...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
    ...(normalizePositiveInteger(editState.clearCountText)
      ? []
      : ['需要消除次数必须为正整数。']),
    ...(normalizeDifficulty(editState.difficultyText)
      ? []
      : ['难度必须为 1 到 10。']),
  ];

  return [...new Set(blockers)];
}

function readImageAsDataUrl(file: File) {
  return new Promise<string>((resolve, reject) => {
    if (!file.type.startsWith('image/')) {
      reject(new Error('请选择图片文件。'));
      return;
    }

    const reader = new FileReader();
    reader.onerror = () => reject(new Error('封面图读取失败，请重试。'));
    reader.onload = () => resolve(String(reader.result || ''));
    reader.readAsDataURL(file);
  });
}

function resolveMatch3DCoverSourceAssets(
  assetDrafts: Match3DItemAssetDraft[],
  backgroundPreviewSrc: string,
): Match3DCoverSourceAsset[] {
  const itemAssets = assetDrafts
    .filter((asset) => asset.referenceImageSrc.trim())
    .map((asset) => ({
      id: `item:${asset.id}`,
      label: asset.name,
      imageSrc: asset.referenceImageSrc,
      kind: 'item' as const,
    }));
  const uiAssets = backgroundPreviewSrc.trim()
    ? [
        {
          id: 'ui:background',
          label: '游戏背景图',
          imageSrc: backgroundPreviewSrc,
          kind: 'ui' as const,
        },
      ]
    : [];
  return [...itemAssets, ...uiAssets];
}

function buildPlayableProfile(
  profile: Match3DWorkProfile,
  editState: Match3DResultEditState,
  generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
) {
  const payload = buildSavePayload(editState);
  if (!payload) {
    return attachMatch3DGeneratedItemAssets(profile, generatedItemAssets);
  }

  return attachMatch3DGeneratedItemAssets(
    {
      ...profile,
      gameName: payload.gameName,
      themeText: payload.themeText ?? profile.themeText,
      summary: payload.summary,
      tags: payload.tags,
      coverImageSrc: payload.coverImageSrc,
      clearCount: payload.clearCount,
      difficulty: payload.difficulty,
    },
    generatedItemAssets,
  );
}

function resolveMatch3DResultGeneratedItemAssets(
  profile: Match3DWorkProfile,
  draft: Match3DResultDraft | null,
) {
  const profileAssets = profile.generatedItemAssets ?? [];
  const draftAssets = draft?.generatedItemAssets ?? [];
  if (draftAssets.length <= 0) {
    return profileAssets;
  }
  if (profileAssets.length <= 0) {
    return draftAssets;
  }

  const profileAssetsById = new Map(
    profileAssets.map((asset) => [asset.itemId, asset]),
  );
  const mergedAssets = draftAssets.map((draftAsset) => {
    const profileAsset = profileAssetsById.get(draftAsset.itemId);
    return profileAsset
      ? mergeMatch3DGeneratedItemAsset(draftAsset, profileAsset)
      : draftAsset;
  });
  for (const profileAsset of profileAssets) {
    if (!mergedAssets.some((asset) => asset.itemId === profileAsset.itemId)) {
      mergedAssets.push(profileAsset);
    }
  }
  return mergedAssets;
}

function attachMatch3DGeneratedItemAssets(
  profile: Match3DWorkProfile,
  generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
  if (generatedItemAssets.length <= 0) {
    return profile;
  }

  // 中文注释：试玩入口依赖当前页面可见的生成素材；保存接口若返回旧快照，不能把素材从运行态入参里丢掉。
  return {
    ...profile,
    generatedItemAssets: [...generatedItemAssets],
  };
}

function attachMatch3DGeneratedBackgroundAsset(
  assets: readonly Match3DGeneratedItemAsset[],
  backgroundAsset: NonNullable<Match3DWorkProfile['generatedBackgroundAsset']>,
) {
  if (assets.length <= 0) {
    return [];
  }

  return assets.map((asset, index) =>
    index === 0 ? { ...asset, backgroundAsset } : asset,
  );
}

function buildPersistableGeneratedItemAssets(
  assetDrafts: Match3DItemAssetDraft[],
  generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
  if (generatedItemAssets.length <= 0) {
    return [];
  }

  return createGeneratedAssetsFromDrafts(
    assetDrafts,
    generatedItemAssets,
  ).filter(hasPersistableMatch3DGeneratedItemAsset);
}

function Match3DResultHeader({
  autoSaveState,
  isBusy,
  onBack,
}: {
  autoSaveState: Match3DAutoSaveState;
  isBusy: boolean;
  onBack: () => void;
}) {
  const badge =
    autoSaveState === 'saving' ? (
      <div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
        保存中
      </div>
    ) : autoSaveState === 'saved' ? (
      <div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
        已自动保存
      </div>
    ) : autoSaveState === 'error' ? (
      <div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
        保存失败
      </div>
    ) : null;

  return (
    <div className="mb-4 flex items-center justify-between gap-3">
      <button
        type="button"
        onClick={onBack}
        disabled={isBusy}
        className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
      >
        <span className="inline-flex items-center gap-1.5">
          <ArrowLeft className="h-3.5 w-3.5" />
          返回
        </span>
      </button>
      {badge}
    </div>
  );
}

function Match3DResultTabs({
  activeTab,
  onChange,
}: {
  activeTab: Match3DResultTab;
  onChange: (tab: Match3DResultTab) => void;
}) {
  return (
    <div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
      {MATCH3D_RESULT_TABS.map((tab) => (
        <button
          key={tab.id}
          type="button"
          onClick={() => onChange(tab.id)}
          className={`min-h-10 rounded-[1rem] px-2 text-sm font-bold transition sm:px-3 ${
            activeTab === tab.id
              ? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
              : 'text-[var(--platform-text-base)] hover:bg-white/60'
          }`}
          aria-pressed={activeTab === tab.id}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

function Match3DWorkInfoTab({
  editState,
  isBusy,
  onChange,
  onOpenCoverPanel,
  onGenerateTags,
}: {
  editState: Match3DResultEditState;
  isBusy: boolean;
  onChange: (nextState: Match3DResultEditState) => void;
  onOpenCoverPanel: () => void;
  onGenerateTags: () => void;
}) {
  const [isAddingTag, setIsAddingTag] = useState(false);
  const [newTagText, setNewTagText] = useState('');
  const tags = normalizeTags(editState.tagsText);
  const updateTags = (nextTags: string[]) => {
    onChange({ ...editState, tagsText: nextTags.join('，') });
  };
  const addTags = () => {
    const nextTags = [
      ...new Set([...tags, ...normalizeMatch3DTagListText(newTagText)]),
    ].slice(0, MATCH3D_MAX_TAG_COUNT);
    updateTags(nextTags);
    setNewTagText('');
    setIsAddingTag(false);
  };

  return (
    <section className="platform-subpanel space-y-3 rounded-[1.35rem] p-4 sm:p-5">
      <div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-2">
        <button
          type="button"
          disabled={isBusy}
          onClick={onOpenCoverPanel}
          className={`h-20 w-28 shrink-0 overflow-hidden rounded-[0.85rem] bg-[radial-gradient(circle_at_35%_24%,rgba(190,242,100,0.28),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))] ${isBusy ? 'cursor-not-allowed opacity-55' : 'transition hover:ring-2 hover:ring-emerald-200'}`}
          aria-label="编辑碰面图"
        >
          {editState.coverImageSrc ? (
            <ResolvedAssetImage
              src={editState.coverImageSrc}
              alt=""
              aria-hidden="true"
              className="h-full w-full object-cover"
            />
          ) : (
            <div className="grid h-full w-full place-items-center text-emerald-700">
              <ImagePlus className="h-6 w-6" />
            </div>
          )}
        </button>
        <button
          type="button"
          disabled={isBusy}
          onClick={onOpenCoverPanel}
          className="platform-button platform-button--ghost flex min-h-10 items-center justify-center gap-2 px-3 py-2 text-sm"
        >
          <ImagePlus className="h-4 w-4" />
          碰面图
        </button>
      </div>

      <label className="block">
        <span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
          作品名称
        </span>
        <input
          value={editState.gameName}
          disabled={isBusy}
          onChange={(event) =>
            onChange({ ...editState, gameName: event.target.value })
          }
          className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
          aria-label="作品名称"
        />
      </label>

      <label className="block">
        <span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
          作品描述
        </span>
        <textarea
          value={editState.summary}
          disabled={isBusy}
          onChange={(event) =>
            onChange({ ...editState, summary: event.target.value })
          }
          rows={6}
          className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
          aria-label="作品描述"
        />
      </label>

      <div>
        <div className="flex items-center justify-between gap-3">
          <div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
            作品标签
          </div>
          <div className="flex items-center gap-2">
            <button
              type="button"
              disabled={isBusy}
              onClick={onGenerateTags}
              className="platform-icon-button h-9 w-9"
              aria-label="AI生成作品标签"
              title="AI生成作品标签"
            >
              {isBusy ? (
                <Loader2 className="h-4 w-4 animate-spin" />
              ) : (
                <Wand2 className="h-4 w-4" />
              )}
            </button>
            {!isAddingTag ? (
              <button
                type="button"
                disabled={isBusy}
                onClick={() => setIsAddingTag(true)}
                className="platform-icon-button h-9 w-9"
                aria-label="新增作品标签"
                title="新增作品标签"
              >
                <Plus className="h-4 w-4" />
              </button>
            ) : null}
          </div>
        </div>

        <div className="mt-3 flex flex-wrap gap-2">
          {tags.map((tag) => (
            <span
              key={tag}
              className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-100/68 px-3 py-1.5 text-xs font-semibold text-emerald-700"
            >
              {tag}
              <button
                type="button"
                disabled={isBusy}
                onClick={() => updateTags(tags.filter((item) => item !== tag))}
                className="rounded-full text-emerald-800/70 transition hover:text-emerald-950 disabled:opacity-45"
                aria-label={`删除标签 ${tag}`}
                title="删除标签"
              >
                <X className="h-3.5 w-3.5" />
              </button>
            </span>
          ))}
          {tags.length <= 0 ? (
            <span className="text-sm text-[var(--platform-text-soft)]">
              暂无标签
            </span>
          ) : null}
        </div>

        {isAddingTag ? (
          <div className="mt-3 flex flex-col gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2 sm:flex-row">
            <input
              autoFocus
              value={newTagText}
              disabled={isBusy}
              onChange={(event) => setNewTagText(event.target.value)}
              onKeyDown={(event) => {
                if (event.key === 'Enter') {
                  event.preventDefault();
                  addTags();
                }
                if (event.key === 'Escape') {
                  setIsAddingTag(false);
                  setNewTagText('');
                }
              }}
              className="min-h-10 flex-1 rounded-[0.8rem] border border-transparent bg-white/92 px-3 text-sm text-[var(--platform-text-strong)] outline-none"
              placeholder="输入新标签"
              aria-label="新题材标签"
            />
            <div className="flex gap-2">
              <button
                type="button"
                disabled={isBusy}
                onClick={addTags}
                className="platform-button platform-button--primary min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
              >
                添加
              </button>
              <button
                type="button"
                disabled={isBusy}
                onClick={() => {
                  setIsAddingTag(false);
                  setNewTagText('');
                }}
                className="platform-button platform-button--ghost min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
              >
                取消
              </button>
            </div>
          </div>
        ) : null}
      </div>
    </section>
  );
}

function Match3DModalShell({
  title,
  children,
  onClose,
}: {
  title: string;
  children: ReactNode;
  onClose: () => void;
}) {
  const platformTheme = useAuthUi()?.platformTheme ?? 'light';
  if (typeof document === 'undefined') {
    return null;
  }

  return createPortal(
    <div
      className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[146] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
      onClick={(event) => {
        if (event.target === event.currentTarget) {
          onClose();
        }
      }}
    >
      <div
        role="dialog"
        aria-modal="true"
        aria-label={title}
        className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,48rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
        onClick={(event) => event.stopPropagation()}
      >
        <div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
          <div className="text-base font-semibold text-[var(--platform-text-strong)]">
            {title}
          </div>
          <button
            type="button"
            onClick={onClose}
            aria-label="关闭"
            className="platform-icon-button"
          >
            <X className="h-4 w-4" />
          </button>
        </div>
        <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
          {children}
        </div>
      </div>
    </div>,
    document.body,
  );
}

function Match3DCoverImagePanel({
  editState,
  sourceAssets,
  isGenerating,
  selectedReferenceSrc,
  aiRedraw,
  prompt,
  error,
  onAiRedrawChange,
  onClose,
  onFileChange,
  onPromptChange,
  onReferenceSelect,
  onSubmit,
}: {
  editState: Match3DResultEditState;
  sourceAssets: Match3DCoverSourceAsset[];
  isGenerating: boolean;
  selectedReferenceSrc: string;
  aiRedraw: boolean;
  prompt: string;
  error: string | null;
  onAiRedrawChange: (enabled: boolean) => void;
  onClose: () => void;
  onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
  onPromptChange: (value: string) => void;
  onReferenceSelect: (source: string) => void;
  onSubmit: () => void;
}) {
  const previewSrc = selectedReferenceSrc || editState.coverImageSrc;
  const canSubmit = Boolean(previewSrc.trim() || prompt.trim());

  return (
    <Match3DModalShell title="碰面图" onClose={onClose}>
      <div className="grid min-h-0 gap-4 lg:grid-cols-[minmax(16rem,0.85fr)_minmax(0,1.15fr)]">
        <div className="space-y-3">
          <div className="aspect-square overflow-hidden rounded-[1.35rem] border border-[var(--platform-subpanel-border)] bg-white/70">
            {previewSrc ? (
              <ResolvedAssetImage
                src={previewSrc}
                alt="碰面图预览"
                className="h-full w-full object-cover"
              />
            ) : (
              <div className="grid h-full w-full place-items-center text-[var(--platform-text-soft)]">
                <ImageIcon className="h-10 w-10" />
              </div>
            )}
          </div>
          <label className="platform-button platform-button--ghost flex min-h-11 cursor-pointer items-center justify-center gap-2 px-4 py-3 text-sm">
            <ImagePlus className="h-4 w-4" />
            上传图片
            <input
              type="file"
              accept="image/*"
              className="sr-only"
              disabled={isGenerating}
              onChange={onFileChange}
            />
          </label>
        </div>

        <div className="space-y-3">
          <label className="flex items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3">
            <span className="text-sm font-semibold text-[var(--platform-text-strong)]">
              AI重绘
            </span>
            <input
              type="checkbox"
              checked={aiRedraw}
              disabled={isGenerating}
              onChange={(event) => onAiRedrawChange(event.target.checked)}
              aria-label="碰面图AI重绘"
            />
          </label>

          <label className="block">
            <span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
              提示词
            </span>
            <textarea
              value={prompt}
              disabled={isGenerating}
              rows={5}
              onChange={(event) => onPromptChange(event.target.value)}
              className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
              aria-label="碰面图提示词"
            />
          </label>

          {sourceAssets.length > 0 ? (
            <div>
              <div className="mb-2 text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
                引用素材
              </div>
              <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
                {sourceAssets.map((asset) => (
                  <button
                    key={asset.id}
                    type="button"
                    disabled={isGenerating}
                    onClick={() => onReferenceSelect(asset.imageSrc)}
                    className={`overflow-hidden rounded-[1rem] border bg-white/74 text-left transition ${
                      selectedReferenceSrc === asset.imageSrc
                        ? 'border-emerald-300 ring-2 ring-emerald-100'
                        : 'border-[var(--platform-subpanel-border)] hover:border-emerald-200'
                    }`}
                    aria-label={`引用${asset.label}`}
                  >
                    <div className="aspect-square overflow-hidden">
                      <ResolvedAssetImage
                        src={asset.imageSrc}
                        alt=""
                        aria-hidden="true"
                        className="h-full w-full object-cover"
                      />
                    </div>
                    <div className="truncate px-2 py-2 text-[11px] font-semibold text-[var(--platform-text-base)]">
                      {asset.label}
                    </div>
                  </button>
                ))}
              </div>
            </div>
          ) : null}

          {error ? (
            <div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
              {error}
            </div>
          ) : null}

          <button
            type="button"
            disabled={!canSubmit || isGenerating}
            onClick={onSubmit}
            className={`platform-button platform-button--primary min-h-11 w-full justify-center gap-2 px-4 py-3 ${!canSubmit || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
          >
            {isGenerating ? (
              <Loader2 className="h-4 w-4 animate-spin" />
            ) : aiRedraw ? (
              <Wand2 className="h-4 w-4" />
            ) : (
              <CheckCircle2 className="h-4 w-4" />
            )}
            {aiRedraw ? '生成碰面图' : '使用当前图片'}
          </button>
        </div>
      </div>
    </Match3DModalShell>
  );
}

function Match3DConfigTab({
  editState,
  isBusy,
  generatedItemAssets,
  totalItemCount,
  onChange,
}: {
  editState: Match3DResultEditState;
  isBusy: boolean;
  generatedItemAssets: readonly Match3DGeneratedItemAsset[];
  totalItemCount: number;
  onChange: (nextState: Match3DResultEditState) => void;
}) {
  const selectedOption = getMatch3DDifficultyOptionFromEditState(editState);
  const selectedOptionIndex = MATCH3D_DIFFICULTY_OPTIONS.findIndex(
    (option) => option.id === selectedOption.id,
  );
  const selectedSliderIndex = Math.max(0, selectedOptionIndex);
  const runtimeTypeCount = selectedOption.itemTypeCount;
  const readyItemTypeCount = getMatch3DReadyItemTypeCount(generatedItemAssets);
  const trackProgress =
    selectedSliderIndex / Math.max(1, MATCH3D_DIFFICULTY_OPTIONS.length - 1);
  const applyDifficultyOption = (option: Match3DDifficultyOption) => {
    onChange({
      ...editState,
      clearCountText: String(option.clearCount),
      difficultyText: String(option.difficulty),
    });
  };
  const handleSliderChange = (event: ChangeEvent<HTMLInputElement>) => {
    const nextIndex = Number.parseInt(event.target.value, 10);
    const nextOption = MATCH3D_DIFFICULTY_OPTIONS[nextIndex];
    if (nextOption) {
      applyDifficultyOption(nextOption);
    }
  };

  return (
    <div className="space-y-3">
      <section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
        <div className="relative px-1 pb-1 pt-2">
          <div className="relative mx-[1.35rem] h-10">
            <div className="absolute left-0 right-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-white/75 shadow-[inset_0_0_0_1px_rgba(244,114,182,0.16)]" />
            <div
              className="absolute left-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[linear-gradient(90deg,#ff8aac_0%,#ff5f7e_54%,#ff9b88_100%)] transition-[width] duration-200"
              style={{ width: `${trackProgress * 100}%` }}
            />
            {MATCH3D_DIFFICULTY_OPTIONS.map((option, index) => {
              const selected = selectedOption.id === option.id;
              return (
                <div
                  key={option.id}
                  aria-hidden="true"
                  className={`absolute top-1/2 flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
                    selected
                      ? 'border-[#ff5f7e] bg-white shadow-[0_8px_18px_rgba(244,63,94,0.2)]'
                      : 'border-rose-100 bg-white/90 hover:border-rose-200'
                  } ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
                  style={{
                    left: `${(index / (MATCH3D_DIFFICULTY_OPTIONS.length - 1)) * 100}%`,
                  }}
                >
                  <span
                    className={`h-3.5 w-3.5 rounded-full ${
                      selected
                        ? 'bg-[var(--platform-accent)]'
                        : 'bg-rose-100'
                    }`}
                  />
                </div>
              );
            })}
            <input
              type="range"
              min={0}
              max={MATCH3D_DIFFICULTY_OPTIONS.length - 1}
              step={1}
              value={selectedSliderIndex}
              disabled={isBusy}
              onChange={handleSliderChange}
              className="absolute inset-x-0 top-1/2 z-10 h-10 -translate-y-1/2 cursor-pointer opacity-0 disabled:cursor-not-allowed"
              aria-label="难度"
              aria-valuetext={selectedOption.label}
            />
          </div>
          <div className="mt-3 grid grid-cols-4 gap-1">
            {MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
              const selected = selectedOption.id === option.id;
              return (
                <button
                  key={option.id}
                  type="button"
                  disabled={isBusy}
                  onClick={() => applyDifficultyOption(option)}
                  className={`rounded-[0.9rem] px-1.5 py-2 text-center transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
                    selected
                      ? 'bg-[#fff1f5] text-[var(--platform-text-strong)] shadow-[inset_0_0_0_1px_rgba(244,63,94,0.18)]'
                      : 'text-[var(--platform-text-base)] hover:bg-white/58'
                  } ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
                  aria-pressed={selected}
                >
                  <div className="text-sm font-black">{option.label}</div>
                  <div className="mt-1 text-[10px] font-bold leading-4 text-[var(--platform-text-soft)]">
                    {option.clearCount}次 · {option.itemTypeCount}种
                  </div>
                </button>
              );
            })}
          </div>
        </div>
        <div className="mt-3 rounded-[1rem] border border-rose-100/80 bg-white/62 px-3 py-3">
          <div className="flex items-center justify-between gap-3">
            <div>
              <div className="text-lg font-black text-[var(--platform-text-strong)]">
                {selectedOption.label}
              </div>
              <div className="mt-1 text-xs font-bold text-[var(--platform-text-base)]">
                {selectedOption.clearCount} 次 · {selectedOption.itemTypeCount}{' '}
                种
              </div>
            </div>
            <div className="rounded-full bg-[var(--platform-accent)] px-3 py-1 text-xs font-black text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]">
              难度 {selectedOption.difficulty}
            </div>
          </div>
        </div>
        <div className="sr-only" aria-live="polite">
          当前难度 {selectedOption.label}
        </div>
      </section>

      <section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
        <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
          {[
            ['需要消除', `${selectedOption.clearCount} 次`],
            ['总物品数', `${totalItemCount} 件`],
            ['物品种类', `${runtimeTypeCount} 种`],
            ['已生成物品种类', `${readyItemTypeCount} 种`],
          ].map(([label, value]) => (
            <div
              key={label}
              className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3"
            >
              <div className="text-[11px] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
                {label}
              </div>
              <div className="mt-1 text-lg font-black text-[var(--platform-text-strong)]">
                {value}
              </div>
            </div>
          ))}
        </div>
      </section>
    </div>
  );
}

function Match3DItemAssetListCard({
  asset,
  active,
  onClick,
  onDelete,
}: {
  asset: Match3DItemAssetDraft;
  active: boolean;
  onClick: () => void;
  onDelete: () => void;
}) {
  const previewSources = resolveMatch3DAssetDraftPreviewSources(asset);
  const previewSource = previewSources[0] ?? asset.referenceImageSrc.trim();

  return (
    <div
      className={`group min-w-0 rounded-[1.15rem] border p-2 text-left transition-colors ${
        active
          ? 'border-rose-300/70 bg-rose-50/80'
          : 'border-[var(--platform-subpanel-border)] bg-white/76 hover:border-rose-200 hover:bg-white'
      }`}
    >
      <div className="grid min-h-full grid-rows-[minmax(0,1fr)_auto] gap-2">
        <button
          type="button"
          onClick={onClick}
          className="grid min-h-0 gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200"
          aria-label={`打开${asset.name}物品素材`}
        >
          <div className="grid aspect-square min-h-0 place-items-center overflow-hidden rounded-[0.95rem] border border-[var(--platform-subpanel-border)] bg-white/82">
            {previewSource ? (
              <ResolvedAssetImage
                src={previewSource}
                alt=""
                aria-hidden="true"
                className="h-full w-full object-contain p-1"
              />
            ) : (
              <ImageIcon className="h-7 w-7 text-[var(--platform-text-soft)]" />
            )}
          </div>
          <span className="truncate text-[13px] font-bold leading-5 text-[var(--platform-text-strong)]">
            {asset.name}
          </span>
        </button>
        <div className="flex min-w-0 justify-end">
          <button
            type="button"
            onClick={onDelete}
            className="platform-icon-button h-8 w-8 shrink-0 text-rose-500"
            aria-label="删除物品素材"
            title="删除"
          >
            <Trash2 className="h-4 w-4" />
          </button>
        </div>
      </div>
    </div>
  );
}

function Match3DItemAssetDetail({
  asset,
  busy,
  soundBusy,
  soundProgress,
  onChange,
  onGenerateClickSound,
}: {
  asset: Match3DItemAssetDraft;
  busy: boolean;
  soundBusy: boolean;
  soundProgress: number | null;
  onChange: (asset: Match3DItemAssetDraft) => void;
  onGenerateClickSound: (asset: Match3DItemAssetDraft) => void;
}) {
  const previewSources = resolveMatch3DAssetDraftPreviewSources(asset);

  return (
    <section className="platform-subpanel min-h-0 rounded-[1.5rem] p-4 sm:p-5">
      <div className="grid min-h-0 gap-4 lg:grid-cols-[minmax(18rem,0.95fr)_minmax(14rem,0.62fr)]">
        <div
          className="grid aspect-square min-h-[18rem] grid-cols-[repeat(5,minmax(0,1fr))] grid-rows-1 gap-2 overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3"
          aria-label={`${asset.name}五视角预览`}
        >
          {previewSources.map((source, index) => (
            <div
              key={`${source}-${index}`}
              className="grid aspect-square h-auto min-h-0 w-full self-center place-items-center overflow-hidden rounded-[0.9rem] bg-white/80"
            >
              <ResolvedAssetImage
                src={source}
                alt=""
                aria-hidden="true"
                className="h-full w-full object-contain"
              />
            </div>
          ))}
          {previewSources.length <= 0 ? (
            <div
              className="col-span-5 grid min-h-0 place-items-center text-[var(--platform-text-soft)]"
              aria-hidden="true"
            >
              <ImageIcon className="h-10 w-10" />
            </div>
          ) : null}
        </div>

        <div className="min-h-0 space-y-3">
          <label className="block">
            <span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
              素材名称
            </span>
            <input
              value={asset.name}
              disabled={busy}
              onChange={(event) =>
                onChange({ ...asset, name: event.target.value })
              }
              className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
            />
          </label>

          <div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3">
            <div className="mb-2 flex items-center justify-between gap-2">
              <span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
                点击音效
              </span>
              <button
                type="button"
                disabled={busy || soundBusy}
                onClick={() => onGenerateClickSound(asset)}
                className={`platform-icon-button h-9 w-9 ${busy || soundBusy ? 'cursor-not-allowed opacity-55' : ''}`}
                aria-label={`生成点击音效，${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
                title={`生成点击音效 · ${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
              >
                {soundBusy ? (
                  <Loader2 className="h-4 w-4 animate-spin" />
                ) : (
                  <Music className="h-4 w-4" />
                )}
              </button>
            </div>
            <label className="block">
              <span className="sr-only">点击音效提示词</span>
              <textarea
                value={asset.soundPrompt}
                disabled={busy || soundBusy}
                rows={4}
                onChange={(event) =>
                  onChange({ ...asset, soundPrompt: event.target.value })
                }
                className="mb-3 w-full resize-none rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-2.5 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
                aria-label={`${asset.name}点击音效提示词`}
              />
            </label>
            {soundBusy && soundProgress !== null ? (
              <Match3DAudioProgress
                label="音效生成中"
                progress={soundProgress}
              />
            ) : null}
            {asset.clickSound?.audioSrc ? (
              <Match3DResolvedAudio
                src={asset.clickSound.audioSrc}
                ariaLabel={`${asset.name}点击音效`}
              />
            ) : (
              <div className="mt-3 text-sm font-semibold text-[var(--platform-text-soft)]">
                暂无音效
              </div>
            )}
          </div>
        </div>
      </div>
    </section>
  );
}

function Match3DAssetsTab({
  activeAssetId,
  assets,
  batchGenerationState,
  soundBusyAssetId,
  soundGenerationProgress,
  onActiveAssetChange,
  onAddBatch,
  onAssetChange,
  onDeleteAsset,
  onGenerateClickSound,
}: {
  activeAssetId: string | null;
  assets: Match3DItemAssetDraft[];
  batchGenerationState: Match3DBatchItemGenerationState;
  soundBusyAssetId: string | null;
  soundGenerationProgress: number | null;
  onActiveAssetChange: (assetId: string | null) => void;
  onAddBatch: () => void;
  onAssetChange: (asset: Match3DItemAssetDraft) => void;
  onDeleteAsset: (assetId: string) => void;
  onGenerateClickSound: (asset: Match3DItemAssetDraft) => void;
}) {
  const activeAsset =
    assets.find((asset) => asset.id === activeAssetId) ?? null;

  return (
    <div className="min-h-0 space-y-3">
      <div className="flex justify-end">
        <button
          type="button"
          onClick={onAddBatch}
          className="platform-button platform-button--ghost min-h-10 gap-2 px-4 py-2 text-sm"
        >
          <Plus className="h-4 w-4" />
          批量新增
        </button>
      </div>
      <Match3DBatchGenerationProgress generationState={batchGenerationState} />
      <section
        className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-4"
        aria-label="抓大鹅 2D 素材列表"
      >
        {assets.map((asset) => (
          <Match3DItemAssetListCard
            key={asset.id}
            asset={asset}
            active={asset.id === activeAssetId}
            onClick={() => onActiveAssetChange(asset.id)}
            onDelete={() => onDeleteAsset(asset.id)}
          />
        ))}
      </section>

      {activeAsset ? (
        <Match3DModalShell
          title={activeAsset.name || '物品素材'}
          onClose={() => onActiveAssetChange(null)}
        >
          <Match3DItemAssetDetail
            asset={activeAsset}
            busy={false}
            soundBusy={soundBusyAssetId === activeAsset.id}
            soundProgress={
              soundBusyAssetId === activeAsset.id
                ? soundGenerationProgress
                : null
            }
            onChange={onAssetChange}
            onGenerateClickSound={onGenerateClickSound}
          />
        </Match3DModalShell>
      ) : null}
    </div>
  );
}

function Match3DBatchAddItemsPanel({
  values,
  generationState,
  error,
  onAddInput,
  onChangeValue,
  onClose,
  onSubmit,
}: {
  values: string[];
  generationState: Match3DBatchItemGenerationState;
  error: string | null;
  onAddInput: () => void;
  onChangeValue: (index: number, value: string) => void;
  onClose: () => void;
  onSubmit: () => void;
}) {
  const parsedNames = normalizeMatch3DItemNameList(values);
  const isGenerating = generationState.phase === 'generating';
  const pointsCost = calculateMatch3DItemAssetsPointsCost(parsedNames.length);

  return (
    <Match3DModalShell title="批量新增物品" onClose={onClose}>
      <div className="space-y-4">
        <div className="space-y-2">
          {values.map((value, index) => (
            <label key={index} className="block">
              <span className="sr-only">物品名称 {index + 1}</span>
              <input
                value={value}
                disabled={isGenerating}
                onChange={(event) => onChangeValue(index, event.target.value)}
                className="w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
                aria-label={`物品名称 ${index + 1}`}
                placeholder="物品名称"
              />
            </label>
          ))}
          <button
            type="button"
            disabled={isGenerating}
            onClick={onAddInput}
            className={`platform-button platform-button--ghost min-h-10 w-full justify-center gap-2 px-4 py-2 text-sm ${isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
          >
            <Plus className="h-4 w-4" />
            新增物品名称
          </button>
        </div>

        <div className="flex flex-wrap gap-2">
          {parsedNames.map((name) => (
            <span
              key={name}
              className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]"
            >
              {name}
            </span>
          ))}
        </div>

        {error ? (
          <div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
            {error}
          </div>
        ) : null}

        <Match3DBatchGenerationProgress generationState={generationState} />

        <button
          type="button"
          disabled={parsedNames.length <= 0 || isGenerating}
          onClick={onSubmit}
          className={`platform-button platform-button--primary min-h-11 w-full justify-center gap-2 px-4 py-3 ${parsedNames.length <= 0 || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
        >
          {isGenerating ? (
            <Loader2 className="h-4 w-4 animate-spin" />
          ) : (
            <Plus className="h-4 w-4" />
          )}
          生成物品素材 · {pointsCost}光点
        </button>
      </div>
    </Match3DModalShell>
  );
}

function Match3DMusicTab({
  assetDrafts,
  editState,
  profileId,
  busy,
  onMusicGenerated,
}: {
  assetDrafts: Match3DItemAssetDraft[];
  editState: Match3DResultEditState;
  profileId: string;
  busy: boolean;
  onMusicGenerated: (
    music: CreationAudioAsset,
    metadata: {
      prompt: string;
      style: string;
      title: string;
    },
  ) => void;
}) {
  const currentMusic = assetDrafts[0]?.backgroundMusic ?? null;
  const [title, setTitle] = useState(
    () =>
      assetDrafts[0]?.backgroundMusicTitle?.trim() ||
      currentMusic?.title?.trim() ||
      `${editState.gameName.trim() || '抓大鹅'}音乐`.slice(0, 40),
  );
  const [tags, setTags] = useState(
    assetDrafts[0]?.backgroundMusicStyle?.trim() ||
      '轻快, 休闲, 消除, instrumental',
  );
  const [statusText, setStatusText] = useState<string | null>(null);
  const [errorText, setErrorText] = useState<string | null>(null);
  const [isGenerating, setIsGenerating] = useState(false);
  const [generationProgress, setGenerationProgress] = useState<number | null>(
    null,
  );
  const canGenerate = title.trim().length > 0;

  const generateMusic = async () => {
    if (!canGenerate || isGenerating) {
      return;
    }
    setIsGenerating(true);
    setGenerationProgress(0.12);
    setStatusText('提交中');
    setErrorText(null);
    try {
      const task = await createBackgroundMusicTask({
        prompt: '',
        title: title.trim(),
        tags: tags.trim() || null,
      });
      setGenerationProgress(0.35);
      setStatusText('生成中');
      const asset = await waitForGeneratedAudioAsset(task.taskId, async () => {
        const result = await publishBackgroundMusicAsset(task.taskId, {
          entityKind: 'match3d_work',
          entityId: profileId,
          slot: 'background_music',
          assetKind: MATCH3D_BACKGROUND_MUSIC_ASSET_KIND,
          profileId,
          storagePrefix: 'match3d_assets',
        });
        setGenerationProgress(result.audioSrc?.trim() ? 0.92 : 0.58);
        setStatusText(result.audioSrc?.trim() ? '转存中' : '生成中');
        return result;
      });
      if (!asset.audioSrc) {
        throw new Error('音频生成完成但缺少播放地址。');
      }
      onMusicGenerated(
        {
          taskId: asset.taskId,
          provider: asset.provider,
          assetObjectId: asset.assetObjectId ?? null,
          assetKind: asset.assetKind ?? MATCH3D_BACKGROUND_MUSIC_ASSET_KIND,
          audioSrc: asset.audioSrc,
          prompt: '',
          title: title.trim(),
          updatedAt: new Date().toISOString(),
        },
        {
          prompt: '',
          style: tags.trim(),
          title: title.trim(),
        },
      );
      setGenerationProgress(1);
      setStatusText('已生成');
    } catch (caughtError) {
      setErrorText(
        caughtError instanceof Error
          ? caughtError.message
          : '背景音乐生成失败。',
      );
      setStatusText(null);
      setGenerationProgress(null);
    } finally {
      setIsGenerating(false);
    }
  };

  return (
    <div className="space-y-3">
      <section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
        <div className="flex items-center justify-between gap-3">
          <div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
            背景音乐
          </div>
          {statusText ? (
            <span className="platform-pill platform-pill--cool px-3 py-1 text-[11px]">
              {statusText}
            </span>
          ) : null}
        </div>
        {isGenerating && generationProgress !== null ? (
          <Match3DAudioProgress
            label="音乐生成中"
            progress={generationProgress}
          />
        ) : null}
        {currentMusic?.audioSrc ? (
          <Match3DResolvedAudio
            src={currentMusic.audioSrc}
            ariaLabel="抓大鹅背景音乐"
          />
        ) : (
          <div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
            <Music className="h-4 w-4" />
            暂无音乐
          </div>
        )}
      </section>

      <section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
        <label className="block">
          <span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
            曲名
          </span>
          <input
            value={title}
            disabled={busy || isGenerating}
            onChange={(event) => setTitle(event.target.value)}
            className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
            aria-label="抓大鹅背景音乐曲名"
          />
        </label>
        <label className="mt-3 block">
          <span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
            风格
          </span>
          <input
            value={tags}
            disabled={busy || isGenerating}
            onChange={(event) => setTags(event.target.value)}
            className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
            aria-label="抓大鹅背景音乐风格"
          />
        </label>
        <button
          type="button"
          disabled={!canGenerate || busy || isGenerating}
          onClick={() => void generateMusic()}
          className={`platform-button platform-button--primary mt-3 min-h-11 w-full justify-center gap-2 ${!canGenerate || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
        >
          {isGenerating ? (
            <Loader2 className="h-4 w-4 animate-spin" />
          ) : (
            <Music className="h-4 w-4" />
          )}
          {currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_BACKGROUND_MUSIC_POINTS_COST}光点
        </button>
      </section>

      {errorText ? (
        <div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
          {errorText}
        </div>
      ) : null}
    </div>
  );
}

function Match3DAssetConfigTabs({
  activeTab,
  onChange,
}: {
  activeTab: Match3DAssetConfigTab;
  onChange: (tab: Match3DAssetConfigTab) => void;
}) {
  return (
    <div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
      {MATCH3D_ASSET_CONFIG_TABS.map((tab) => (
        <button
          key={tab.id}
          type="button"
          onClick={() => onChange(tab.id)}
          className={`${MATCH3D_MATERIAL_TAB_BUTTON_CLASS} ${
            activeTab === tab.id
              ? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
              : 'text-[var(--platform-text-base)] hover:bg-white/60'
          }`}
          aria-pressed={activeTab === tab.id}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

function Match3DUIAssetsTab({
  backgroundPreviewSrc,
  containerPreviewSrc,
  backgroundPrompt,
  busy,
  isGenerating,
  error,
  onGenerate,
}: {
  backgroundPreviewSrc: string;
  containerPreviewSrc: string;
  backgroundPrompt: string;
  busy: boolean;
  isGenerating: boolean;
  error: string | null;
  onGenerate: (prompt: string) => void;
}) {
  const [prompt, setPrompt] = useState(backgroundPrompt);
  const [isPreviewOpen, setIsPreviewOpen] = useState(false);

  useEffect(() => {
    setPrompt(backgroundPrompt);
  }, [backgroundPrompt]);

  const normalizedPrompt = prompt.trim();

  return (
    <div className="space-y-3">
      <section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
        <div className="grid gap-4 lg:grid-cols-[minmax(14rem,0.72fr)_minmax(0,1fr)]">
          <button
            type="button"
            onClick={() => setIsPreviewOpen(true)}
            className="mx-auto aspect-[9/16] max-h-[min(62dvh,34rem)] w-full max-w-[18rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 text-left shadow-sm"
            aria-label="打开UI页面预览"
          >
            <ResolvedAssetImage
              src={backgroundPreviewSrc}
              alt="游戏背景图"
              className="h-full w-full object-cover"
            />
            <span className="sr-only">打开UI页面预览</span>
          </button>

          <div className="flex min-h-0 flex-col">
            <label className="block">
              <span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
                画面描述提示词
              </span>
              <textarea
                value={prompt}
                disabled={busy || isGenerating}
                rows={7}
                onChange={(event) => setPrompt(event.target.value)}
                className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
                aria-label="UI背景图画面描述提示词"
              />
            </label>
            <div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
              <button
                type="button"
                onClick={() => setIsPreviewOpen(true)}
                className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
              >
                <Eye className="h-4 w-4" />
                预览UI页面
              </button>
              <button
                type="button"
                disabled={!normalizedPrompt || busy || isGenerating}
                onClick={() => onGenerate(normalizedPrompt)}
                className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
              >
                {isGenerating ? (
                  <Loader2 className="h-4 w-4 animate-spin" />
                ) : (
                  <Wand2 className="h-4 w-4" />
                )}
                重新生成 · {MATCH3D_UI_BACKGROUND_POINTS_COST}光点
              </button>
            </div>
          </div>
        </div>
      </section>

      {error ? (
        <div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
          {error}
        </div>
      ) : null}

      {isPreviewOpen ? (
        <Match3DUIRuntimePreviewPanel
          backgroundPreviewSrc={backgroundPreviewSrc}
          containerPreviewSrc={containerPreviewSrc}
          onClose={() => setIsPreviewOpen(false)}
        />
      ) : null}
    </div>
  );
}

function Match3DUIRuntimePreviewPanel({
  backgroundPreviewSrc,
  containerPreviewSrc,
  onClose,
}: {
  backgroundPreviewSrc: string;
  containerPreviewSrc: string;
  onClose: () => void;
}) {
  return (
    <Match3DModalShell title="UI预览" onClose={onClose}>
      <div className="mx-auto aspect-[9/16] max-h-[min(78dvh,44rem)] w-full max-w-[24rem] overflow-hidden rounded-[1.4rem] border border-white/18 bg-[#16221f] shadow-[0_18px_55px_rgba(15,23,42,0.24)]">
        <div className="relative flex h-full w-full flex-col overflow-hidden px-3 pb-4 pt-3 text-white">
          <div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
          <ResolvedAssetImage
            src={backgroundPreviewSrc}
            alt=""
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 h-full w-full object-cover"
          />
          <header className="relative z-10 flex items-center justify-between gap-2">
            <span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
              <ArrowLeft size={20} />
            </span>
            <span className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>
              1:30
            </span>
            <span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
              <span className={MATCH3D_RUNTIME_GLASS_SPINNER_CLASS} />
            </span>
          </header>

          <section className="relative z-10 mt-3 flex min-h-0 flex-1 items-center justify-center">
            <div
              className="relative aspect-square max-w-full rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
              style={{ width: 'min(92%, 58dvh, 100%)' }}
              aria-hidden="true"
            >
              {containerPreviewSrc ? (
                <ResolvedAssetImage
                  src={containerPreviewSrc}
                  alt=""
                  aria-hidden="true"
                  className="pointer-events-none absolute inset-[-4%] h-[108%] w-[108%] object-contain"
                />
              ) : (
                <div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
              )}
            </div>
          </section>

          <section className={`relative z-10 ${MATCH3D_RUNTIME_GLASS_TRAY_CLASS}`}>
            <div className="grid grid-cols-7 gap-1.5">
              {Array.from({ length: 7 }).map((_, index) => (
                <span
                  key={index}
                  className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
                />
              ))}
            </div>
          </section>
        </div>
      </div>
    </Match3DModalShell>
  );
}

function Match3DAssetConfigTab({
  activeAssetConfigTab,
  activeAssetId,
  assetDrafts,
  backgroundPreviewSrc,
  containerPreviewSrc,
  backgroundPrompt,
  backgroundGenerationError,
  batchGenerationState,
  busy,
  editState,
  isGeneratingBackground,
  profileId,
  soundBusyAssetId,
  soundGenerationProgress,
  onActiveAssetChange,
  onAddBatch,
  onAssetChange,
  onAssetConfigTabChange,
  onDeleteAsset,
  onGenerateBackground,
  onGenerateClickSound,
  onMusicGenerated,
}: {
  activeAssetConfigTab: Match3DAssetConfigTab;
  activeAssetId: string | null;
  assetDrafts: Match3DItemAssetDraft[];
  backgroundPreviewSrc: string;
  containerPreviewSrc: string;
  backgroundPrompt: string;
  backgroundGenerationError: string | null;
  batchGenerationState: Match3DBatchItemGenerationState;
  busy: boolean;
  editState: Match3DResultEditState;
  isGeneratingBackground: boolean;
  profileId: string;
  soundBusyAssetId: string | null;
  soundGenerationProgress: number | null;
  onActiveAssetChange: (assetId: string | null) => void;
  onAddBatch: () => void;
  onAssetChange: (asset: Match3DItemAssetDraft) => void;
  onAssetConfigTabChange: (tab: Match3DAssetConfigTab) => void;
  onDeleteAsset: (assetId: string) => void;
  onGenerateBackground: (prompt: string) => void;
  onGenerateClickSound: (asset: Match3DItemAssetDraft) => void;
  onMusicGenerated: (
    music: CreationAudioAsset,
    metadata: {
      prompt: string;
      style: string;
      title: string;
    },
  ) => void;
}) {
  return (
    <div className="min-h-0">
      <Match3DAssetConfigTabs
        activeTab={activeAssetConfigTab}
        onChange={onAssetConfigTabChange}
      />
      {activeAssetConfigTab === 'items' ? (
        <Match3DAssetsTab
          activeAssetId={activeAssetId}
          assets={assetDrafts}
          batchGenerationState={batchGenerationState}
          soundBusyAssetId={soundBusyAssetId}
          soundGenerationProgress={soundGenerationProgress}
          onActiveAssetChange={onActiveAssetChange}
          onAddBatch={onAddBatch}
          onAssetChange={onAssetChange}
          onDeleteAsset={onDeleteAsset}
          onGenerateClickSound={onGenerateClickSound}
        />
      ) : null}
      {activeAssetConfigTab === 'ui' ? (
        <Match3DUIAssetsTab
          backgroundPreviewSrc={backgroundPreviewSrc}
          containerPreviewSrc={containerPreviewSrc}
          backgroundPrompt={backgroundPrompt}
          busy={busy}
          isGenerating={isGeneratingBackground}
          error={backgroundGenerationError}
          onGenerate={onGenerateBackground}
        />
      ) : null}
      {activeAssetConfigTab === 'music' ? (
        <Match3DMusicTab
          assetDrafts={assetDrafts}
          editState={editState}
          profileId={profileId}
          busy={busy}
          onMusicGenerated={onMusicGenerated}
        />
      ) : null}
    </div>
  );
}

export function Match3DResultView({
  profile,
  draft = null,
  isBusy = false,
  error = null,
  onBack,
  onSaved,
  onPublished,
  onStartTestRun,
}: Match3DResultViewProps) {
  const [editState, setEditState] = useState(() => createEditState(profile));
  const [activeTab, setActiveTab] = useState<Match3DResultTab>('work');
  const [activeAssetConfigTab, setActiveAssetConfigTab] =
    useState<Match3DAssetConfigTab>('items');
  const [assetDrafts, setAssetDrafts] = useState<Match3DItemAssetDraft[]>(() =>
    createMatch3DAssetDrafts(profile, draft),
  );
  const [activeAssetId, setActiveAssetId] = useState<string | null>(null);
  const [isCoverPanelOpen, setIsCoverPanelOpen] = useState(false);
  const [coverReferenceSrc, setCoverReferenceSrc] = useState('');
  const [coverPrompt, setCoverPrompt] = useState('');
  const [coverAiRedraw, setCoverAiRedraw] = useState(false);
  const [isGeneratingCover, setIsGeneratingCover] = useState(false);
  const [coverPanelError, setCoverPanelError] = useState<string | null>(null);
  const [isBatchAddPanelOpen, setIsBatchAddPanelOpen] = useState(false);
  const [batchItemNameValues, setBatchItemNameValues] = useState(['']);
  const [batchGenerationState, setBatchGenerationState] =
    useState<Match3DBatchItemGenerationState>({
      phase: 'idle',
      progress: null,
      itemNames: [],
      message: null,
      error: null,
    });
  const [batchAddError, setBatchAddError] = useState<string | null>(null);
  const [isGeneratingBackground, setIsGeneratingBackground] = useState(false);
  const [backgroundGenerationError, setBackgroundGenerationError] = useState<
    string | null
  >(null);
  const [soundBusyAssetId, setSoundBusyAssetId] = useState<string | null>(null);
  const [soundGenerationProgress, setSoundGenerationProgress] = useState<
    number | null
  >(null);
  const [autoSaveState, setAutoSaveState] =
    useState<Match3DAutoSaveState>('idle');
  const [localError, setLocalError] = useState<string | null>(null);
  const [isPublishing, setIsPublishing] = useState(false);
  const [isStartingTestRun, setIsStartingTestRun] = useState(false);
  const [isGeneratingTags, setIsGeneratingTags] = useState(false);
  const generatedItemAssets = useMemo(
    () => resolveMatch3DResultGeneratedItemAssets(profile, draft),
    [draft, profile],
  );
  const blockers = useMemo(
    () => buildPublishBlockers(editState, generatedItemAssets),
    [editState, generatedItemAssets],
  );
  const testRunBlockers = useMemo(
    () => buildTestRunBlockers(editState),
    [editState],
  );
  const canStartTestRun = testRunBlockers.length === 0;
  const canSubmit = blockers.length === 0;
  const totalItemCount =
    (normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
    3;
  const backgroundPreviewSrc = useMemo(
    () =>
      resolveMatch3DBackgroundPreviewSource(
        profile,
        draft,
        generatedItemAssets,
      ),
    [draft, generatedItemAssets, profile],
  );
  const backgroundPrompt = useMemo(
    () => resolveMatch3DBackgroundPrompt(profile, draft, generatedItemAssets),
    [draft, generatedItemAssets, profile],
  );
  const containerPreviewSrc = useMemo(
    () =>
      resolveMatch3DContainerPreviewSource(generatedItemAssets) ||
      MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
    [generatedItemAssets],
  );
  const coverSourceAssets = useMemo(
    () => resolveMatch3DCoverSourceAssets(assetDrafts, backgroundPreviewSrc),
    [assetDrafts, backgroundPreviewSrc],
  );

  useEffect(() => {
    setEditState(createEditState(profile));
    setAutoSaveState('idle');
    setLocalError(null);
    setCoverReferenceSrc('');
    setCoverPrompt('');
    setCoverAiRedraw(false);
    setCoverPanelError(null);
    setBackgroundGenerationError(null);
    setIsGeneratingBackground(false);
    // 中文注释：表单草稿只在切换作品或服务端更新时间变化时重置，避免父级重渲染打断本地编辑。
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [profile.profileId, profile.updatedAt]);

  useEffect(() => {
    setAssetDrafts(createMatch3DAssetDrafts(profile, draft));
    setActiveAssetId(null);
    setSoundBusyAssetId(null);
    setSoundGenerationProgress(null);
    // 中文注释：素材草稿只跟随持久化素材字段和作品切换重建，避免无关 profile 字段刷新关闭当前面板。
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    draft?.generatedItemAssets,
    profile.generatedItemAssets,
    profile.profileId,
  ]);

  useEffect(() => {
    const payload = buildSavePayload(editState);
    if (!payload) {
      return undefined;
    }

    const currentTags = normalizeTags(profile.tags.join('，'));
    const nextTags = payload.tags;
    const changed =
      payload.gameName !== profile.gameName ||
      payload.themeText !== profile.themeText ||
      payload.summary !== profile.summary ||
      (payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') ||
      payload.clearCount !== profile.clearCount ||
      payload.difficulty !== profile.difficulty ||
      nextTags.length !== currentTags.length ||
      nextTags.some((tag, index) => tag !== currentTags[index]);

    if (!changed) {
      return undefined;
    }

    setAutoSaveState('saving');
    setLocalError(null);
    let cancelled = false;
    const timer = window.setTimeout(() => {
      void updateMatch3DWork(profile.profileId, payload)
        .then(({ item }) => {
          if (cancelled) {
            return;
          }
          const playableItem = attachMatch3DGeneratedItemAssets(
            item,
            generatedItemAssets,
          );
          setAutoSaveState('saved');
          onSaved?.(playableItem);
        })
        .catch((saveError) => {
          if (cancelled) {
            return;
          }
          if (isGeneratingBackground) {
            return;
          }
          setAutoSaveState('error');
          setLocalError(
            saveError instanceof Error ? saveError.message : '自动保存失败。',
          );
        });
    }, MATCH3D_AUTOSAVE_DEBOUNCE_MS);

    return () => {
      cancelled = true;
      window.clearTimeout(timer);
    };
  }, [editState, generatedItemAssets, isGeneratingBackground, onSaved, profile]);

  const saveNow = async () => {
    const payload = buildSavePayload(editState);
    if (!payload) {
      setLocalError(testRunBlockers[0] ?? '请补全作品信息。');
      return null;
    }

    setAutoSaveState('saving');
    setLocalError(null);
    const { item } = await updateMatch3DWork(profile.profileId, payload);
    const currentGeneratedItemAssets = buildPersistableGeneratedItemAssets(
      assetDrafts,
      generatedItemAssets,
    );
    let playableItem = attachMatch3DGeneratedItemAssets(
      item,
      currentGeneratedItemAssets.length > 0
        ? currentGeneratedItemAssets
        : generatedItemAssets,
    );
    if (
      shouldPersistGeneratedItemAssets(
        currentGeneratedItemAssets,
        item.generatedItemAssets ?? [],
      )
    ) {
      // 中文注释：试玩和发布前必须先把当前可见 2D 多视角素材写回 profile。
      const { item: persistedItem } = await updateMatch3DGeneratedItemAssets(
        profile.profileId,
        {
          generatedItemAssets: currentGeneratedItemAssets,
        },
      );
      playableItem = attachMatch3DGeneratedItemAssets(
        persistedItem,
        currentGeneratedItemAssets,
      );
    }
    setAutoSaveState('saved');
    onSaved?.(playableItem);
    return playableItem;
  };

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

    try {
      const dataUrl = await readImageAsDataUrl(file);
      setCoverReferenceSrc(dataUrl);
      setCoverAiRedraw(true);
      setCoverPanelError(null);
    } catch (caughtError) {
      setCoverPanelError(
        caughtError instanceof Error ? caughtError.message : '封面图读取失败。',
      );
    }
  };

  const handleOpenCoverPanel = () => {
    setCoverReferenceSrc(editState.coverImageSrc);
    setCoverPrompt(
      [
        editState.gameName.trim(),
        editState.themeText.trim(),
        '抓大鹅作品碰面图，主体清晰，适合作品卡片',
      ]
        .filter(Boolean)
        .join('，'),
    );
    setCoverAiRedraw(false);
    setCoverPanelError(null);
    setIsCoverPanelOpen(true);
  };

  const handleSubmitCoverPanel = async () => {
    const referenceSrc = coverReferenceSrc.trim();
    const prompt = coverPrompt.trim();
    if (!referenceSrc && !prompt) {
      setCoverPanelError('请上传图片、引用素材或填写提示词。');
      return;
    }
    if (!coverAiRedraw) {
      setEditState((current) => ({
        ...current,
        coverImageSrc: referenceSrc,
      }));
      setIsCoverPanelOpen(false);
      setCoverPanelError(null);
      return;
    }

    setIsGeneratingCover(true);
    setCoverPanelError(null);
    try {
      const response = await generateMatch3DCoverImage(profile.profileId, {
        prompt:
          prompt ||
          `${editState.gameName.trim() || '抓大鹅'}作品碰面图，${editState.themeText.trim() || '休闲消除'}题材`,
        referenceImageSrc: referenceSrc || null,
      });
      setEditState((current) => ({
        ...current,
        coverImageSrc: response.coverImageSrc,
      }));
      setIsCoverPanelOpen(false);
      onSaved?.(response.item);
    } catch (caughtError) {
      setCoverPanelError(
        caughtError instanceof Error ? caughtError.message : '碰面图生成失败。',
      );
    } finally {
      setIsGeneratingCover(false);
    }
  };

  const handleGenerateTags = async () => {
    const gameName = editState.gameName.trim();
    const themeText = editState.themeText.trim();
    if (!gameName || !themeText) {
      setLocalError('请先补齐作品名称和题材主题。');
      return;
    }

    setIsGeneratingTags(true);
    try {
      const response = await generateMatch3DWorkTags({
        gameName,
        themeText,
        summary: editState.summary.trim(),
      });
      const nextTags = normalizeTags(response.tags.join('，'));
      if (nextTags.length <= 0) {
        throw new Error('未生成有效标签。');
      }
      setEditState((current) => ({
        ...current,
        tagsText: nextTags.join('，'),
      }));
      setLocalError(null);
    } catch (caughtError) {
      setLocalError(
        caughtError instanceof Error
          ? caughtError.message
          : 'AI 生成标签失败。',
      );
    } finally {
      setIsGeneratingTags(false);
    }
  };

  const updateItemAsset = (nextAsset: Match3DItemAssetDraft) => {
    setAssetDrafts((currentAssets) =>
      currentAssets.map((asset) =>
        asset.id === nextAsset.id ? nextAsset : asset,
      ),
    );
  };

  const persistAudioAssetDrafts = async (
    nextDrafts: Match3DItemAssetDraft[],
  ) => {
    return persistGeneratedAssetDrafts(nextDrafts);
  };

  const persistGeneratedAssetDrafts = async (
    nextDrafts: Match3DItemAssetDraft[],
  ) => {
    const nextAssets = createGeneratedAssetsFromDrafts(
      nextDrafts,
      profile.generatedItemAssets ?? [],
    );
    const { item } = await updateMatch3DGeneratedItemAssets(profile.profileId, {
      generatedItemAssets: nextAssets,
    });
    onSaved?.(item);
    return item;
  };

  const handleDeleteAssetDraft = async (assetId: string) => {
    if (busy) {
      return;
    }
    const nextDrafts = assetDrafts.filter((asset) => asset.id !== assetId);
    setAssetDrafts(nextDrafts);
    if (activeAssetId === assetId) {
      setActiveAssetId(null);
    }
    try {
      await persistGeneratedAssetDrafts(nextDrafts);
      setLocalError(null);
    } catch (caughtError) {
      setAssetDrafts(assetDrafts);
      setLocalError(
        caughtError instanceof Error
          ? caughtError.message
          : '删除物品素材失败。',
      );
    }
  };

  const handleSubmitBatchAddItems = () => {
    const itemNames = normalizeMatch3DItemNameList(batchItemNameValues);
    if (itemNames.length <= 0 || batchGenerationState.phase === 'generating') {
      setBatchAddError('请填写至少一个物品名称。');
      return;
    }

    setBatchAddError(null);
    setBatchGenerationState({
      phase: 'generating',
      progress: 0.08,
      itemNames,
      message: `正在生成 ${itemNames.length} 种物品素材`,
      error: null,
    });

    let progressTick = 0;
    const progressTimer = window.setInterval(() => {
      progressTick += 1;
      setBatchGenerationState((current) => {
        if (current.phase !== 'generating') {
          return current;
        }
        const currentProgress = current.progress ?? 0.08;
        const ceiling = progressTick < 4 ? 0.42 : 0.88;
        return {
          ...current,
          progress: Math.min(ceiling, currentProgress + 0.08),
        };
      });
    }, 1200);

    void generateMatch3DItemAssets(profile.profileId, { itemNames })
      .then((response) => {
        window.clearInterval(progressTimer);
        const refreshedProfile = attachMatch3DGeneratedItemAssets(
          response.item,
          response.generatedItemAssets,
        );
        setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null));
        setBatchItemNameValues(['']);
        setBatchGenerationState({
          phase: 'done',
          progress: 1,
          itemNames,
          message: `已生成 ${itemNames.length} 种物品素材`,
          error: null,
        });
        setLocalError(null);
        onSaved?.(refreshedProfile);
      })
      .catch((caughtError) => {
        window.clearInterval(progressTimer);
        const message =
          caughtError instanceof Error
            ? caughtError.message
            : '批量新增物品素材失败。';
        setBatchAddError(message);
        setBatchGenerationState({
          phase: 'failed',
          progress: null,
          itemNames,
          message: null,
          error: message,
        });
      });
  };

  const handleGenerateBackground = async (prompt: string) => {
    const normalizedPrompt = prompt.trim();
    if (!normalizedPrompt || isGeneratingBackground) {
      setBackgroundGenerationError('请填写画面描述提示词。');
      return;
    }

    setIsGeneratingBackground(true);
    setBackgroundGenerationError(null);
    try {
      const response = await generateMatch3DBackgroundImage(profile.profileId, {
        prompt: normalizedPrompt,
      });
      const nextGeneratedAssets = attachMatch3DGeneratedBackgroundAsset(
        response.item.generatedItemAssets?.length
          ? response.item.generatedItemAssets
          : generatedItemAssets,
        response.generatedBackgroundAsset,
      );
      const refreshedProfile = attachMatch3DGeneratedItemAssets(
        response.item,
        nextGeneratedAssets,
      );
      setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null));
      onSaved?.(refreshedProfile);
      setLocalError(null);
    } catch (caughtError) {
      setBackgroundGenerationError(
        caughtError instanceof Error
          ? caughtError.message
          : 'UI背景图生成失败。',
      );
    } finally {
      setIsGeneratingBackground(false);
    }
  };

  const patchAndPersistAudioAssetDrafts = async (
    patcher: (drafts: Match3DItemAssetDraft[]) => Match3DItemAssetDraft[],
  ) => {
    const nextDrafts = patcher(assetDrafts);
    setAssetDrafts(nextDrafts);
    await persistAudioAssetDrafts(nextDrafts);
  };

  const handleBackgroundMusicGenerated = async (
    music: CreationAudioAsset,
    metadata: {
      prompt: string;
      style: string;
      title: string;
    },
  ) => {
    try {
      await patchAndPersistAudioAssetDrafts((drafts) => {
        if (drafts.length <= 0) {
          return drafts;
        }
        return drafts.map((asset, index) =>
          index === 0
            ? {
                ...asset,
                backgroundMusic: music,
                backgroundMusicPrompt: metadata.prompt,
                backgroundMusicStyle: metadata.style,
                backgroundMusicTitle: metadata.title,
              }
            : asset,
        );
      });
      setLocalError(null);
    } catch (caughtError) {
      setLocalError(
        caughtError instanceof Error
          ? caughtError.message
          : '保存背景音乐失败。',
      );
    }
  };

  const handleGenerateClickSound = async (asset: Match3DItemAssetDraft) => {
    if (soundBusyAssetId) {
      return;
    }
    setSoundBusyAssetId(asset.id);
    setSoundGenerationProgress(0.12);
    setLocalError(null);
    try {
      const prompt =
        asset.soundPrompt.trim() ||
        buildFallbackMatch3DClickSoundPrompt(
          { themeText: editState.themeText },
          asset.name,
        );
      const task = await createSoundEffectTask({
        prompt,
        duration: 3,
      });
      setSoundGenerationProgress(0.35);
      const generated = await waitForGeneratedAudioAsset(
        task.taskId,
        async () => {
          const result = await publishSoundEffectAsset(task.taskId, {
            entityKind: 'match3d_item',
            entityId: asset.id,
            slot: 'click_sound',
            assetKind: MATCH3D_CLICK_SOUND_ASSET_KIND,
            profileId: profile.profileId,
            storagePrefix: 'match3d_assets',
          });
          setSoundGenerationProgress(result.audioSrc?.trim() ? 0.92 : 0.58);
          return result;
        },
      );
      if (!generated.audioSrc) {
        throw new Error('音效生成完成但缺少播放地址。');
      }
      const clickSound: CreationAudioAsset = {
        taskId: generated.taskId,
        provider: generated.provider,
        assetObjectId: generated.assetObjectId ?? null,
        assetKind: generated.assetKind ?? MATCH3D_CLICK_SOUND_ASSET_KIND,
        audioSrc: generated.audioSrc,
        prompt,
        title: `${asset.name}点击音效`,
        updatedAt: new Date().toISOString(),
      };
      await patchAndPersistAudioAssetDrafts((drafts) =>
        drafts.map((draftAsset) =>
          draftAsset.id === asset.id
            ? {
                ...draftAsset,
                clickSound,
                soundPrompt: prompt,
                updatedAt: new Date().toISOString(),
              }
            : draftAsset,
        ),
      );
      setSoundGenerationProgress(1);
    } catch (caughtError) {
      setLocalError(
        caughtError instanceof Error
          ? caughtError.message
          : '点击音效生成失败。',
      );
    } finally {
      setSoundBusyAssetId(null);
      window.setTimeout(() => setSoundGenerationProgress(null), 350);
    }
  };

  const handleStartTestRun = async () => {
    if (!canStartTestRun || isStartingTestRun) {
      setLocalError(testRunBlockers[0] ?? null);
      return;
    }

    setIsStartingTestRun(true);
    try {
      const savedProfile = await saveNow();
      const playableProfile =
        savedProfile ??
        buildPlayableProfile(profile, editState, generatedItemAssets);
      const playableAssets =
        playableProfile.generatedItemAssets ?? generatedItemAssets;
      const targetItemTypeCount =
        getMatch3DDifficultyOptionFromEditState(editState).itemTypeCount;
      const playableItemTypeCount = getMatch3DPlayableItemTypeCount(
        targetItemTypeCount,
        playableAssets,
      );
      onStartTestRun(playableProfile, {
        itemTypeCountOverride: playableItemTypeCount,
      });
    } catch (caughtError) {
      setLocalError(
        caughtError instanceof Error
          ? caughtError.message
          : '启动试玩前保存失败。',
      );
    } finally {
      setIsStartingTestRun(false);
    }
  };

  const handlePublish = async () => {
    if (!canSubmit || isPublishing) {
      setLocalError(blockers[0] ?? null);
      return;
    }

    setIsPublishing(true);
    try {
      const savedProfile = await saveNow();
      const { item } = await publishMatch3DWork(
        savedProfile?.profileId ?? profile.profileId,
      );
      onPublished?.(
        attachMatch3DGeneratedItemAssets(
          item,
          savedProfile?.generatedItemAssets ?? generatedItemAssets,
        ),
      );
      setLocalError(null);
    } catch (caughtError) {
      setLocalError(
        caughtError instanceof Error
          ? caughtError.message
          : '发布抓大鹅作品失败。',
      );
    } finally {
      setIsPublishing(false);
    }
  };

  const busy =
    isBusy ||
    isPublishing ||
    isStartingTestRun ||
    isGeneratingCover ||
    isGeneratingBackground ||
    Boolean(soundBusyAssetId);
  const workBusy = busy || isGeneratingTags;
  const displayError = error ?? localError;

  return (
    <div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)]">
      <Match3DResultHeader
        autoSaveState={autoSaveState}
        isBusy={busy}
        onBack={onBack}
      />

      <Match3DResultTabs activeTab={activeTab} onChange={setActiveTab} />

      <div className="min-h-0 flex-1 overflow-y-auto pr-1">
        {activeTab === 'work' ? (
          <Match3DWorkInfoTab
            editState={editState}
            isBusy={workBusy}
            onChange={setEditState}
            onOpenCoverPanel={handleOpenCoverPanel}
            onGenerateTags={handleGenerateTags}
          />
        ) : null}
        {activeTab === 'config' ? (
          <Match3DConfigTab
            editState={editState}
            generatedItemAssets={generatedItemAssets}
            isBusy={busy}
            totalItemCount={totalItemCount}
            onChange={setEditState}
          />
        ) : null}
        {activeTab === 'assets' ? (
          <Match3DAssetConfigTab
            activeAssetConfigTab={activeAssetConfigTab}
            activeAssetId={activeAssetId}
            assetDrafts={assetDrafts}
            backgroundPreviewSrc={backgroundPreviewSrc}
            containerPreviewSrc={containerPreviewSrc}
            backgroundPrompt={backgroundPrompt}
            backgroundGenerationError={backgroundGenerationError}
            batchGenerationState={batchGenerationState}
            busy={busy}
            editState={editState}
            isGeneratingBackground={isGeneratingBackground}
            profileId={profile.profileId}
            soundBusyAssetId={soundBusyAssetId}
            soundGenerationProgress={soundGenerationProgress}
            onActiveAssetChange={setActiveAssetId}
            onAddBatch={() => {
              setBatchAddError(null);
              setBatchGenerationState((current) =>
                current.phase === 'generating'
                  ? current
                  : {
                      phase: 'idle',
                      progress: null,
                      itemNames: [],
                      message: null,
                      error: null,
                    },
              );
              setIsBatchAddPanelOpen(true);
            }}
            onAssetChange={updateItemAsset}
            onAssetConfigTabChange={setActiveAssetConfigTab}
            onDeleteAsset={(assetId) => {
              void handleDeleteAssetDraft(assetId);
            }}
            onGenerateBackground={(prompt) => {
              void handleGenerateBackground(prompt);
            }}
            onGenerateClickSound={(asset) => {
              void handleGenerateClickSound(asset);
            }}
            onMusicGenerated={(music, metadata) => {
              void handleBackgroundMusicGenerated(music, metadata);
            }}
          />
        ) : null}
      </div>

      {displayError ? (
        <div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
          {displayError}
        </div>
      ) : null}

      <div className="mt-3 flex flex-col gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:flex-row sm:justify-end">
        <button
          type="button"
          onClick={handleStartTestRun}
          disabled={!canStartTestRun || busy}
          className={`platform-button platform-button--ghost min-h-11 justify-center gap-2 px-5 py-3 ${!canStartTestRun || busy ? 'cursor-not-allowed opacity-55' : ''}`}
        >
          {isStartingTestRun ? (
            <Loader2 className="h-4 w-4 animate-spin" />
          ) : (
            <Play className="h-4 w-4" />
          )}
          试玩
        </button>
        <button
          type="button"
          onClick={handlePublish}
          disabled={!canSubmit || busy}
          className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
        >
          {isPublishing ? (
            <Loader2 className="h-4 w-4 animate-spin" />
          ) : profile.publicationStatus === 'published' ? (
            <CheckCircle2 className="h-4 w-4" />
          ) : (
            <Send className="h-4 w-4" />
          )}
          {profile.publicationStatus === 'published' ? '更新发布' : '发布'}
        </button>
      </div>

      {isCoverPanelOpen ? (
        <Match3DCoverImagePanel
          editState={editState}
          sourceAssets={coverSourceAssets}
          isGenerating={isGeneratingCover}
          selectedReferenceSrc={coverReferenceSrc}
          aiRedraw={coverAiRedraw}
          prompt={coverPrompt}
          error={coverPanelError}
          onAiRedrawChange={setCoverAiRedraw}
          onClose={() => {
            if (!isGeneratingCover) {
              setIsCoverPanelOpen(false);
            }
          }}
          onFileChange={handleCoverImageChange}
          onPromptChange={setCoverPrompt}
          onReferenceSelect={(source) => {
            setCoverReferenceSrc(source);
            setCoverAiRedraw(true);
            setCoverPanelError(null);
          }}
          onSubmit={() => {
            void handleSubmitCoverPanel();
          }}
        />
      ) : null}

      {isBatchAddPanelOpen ? (
        <Match3DBatchAddItemsPanel
          values={batchItemNameValues}
          generationState={batchGenerationState}
          error={batchAddError}
          onAddInput={() => {
            setBatchItemNameValues((current) => [...current, '']);
          }}
          onChangeValue={(index, value) => {
            setBatchItemNameValues((current) =>
              current.map((item, itemIndex) =>
                itemIndex === index ? value : item,
              ),
            );
          }}
          onClose={() => {
            setIsBatchAddPanelOpen(false);
          }}
          onSubmit={() => {
            handleSubmitBatchAddItems();
          }}
        />
      ) : null}
    </div>
  );
}

export default Match3DResultView;
