import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
  AssistantRuntimeProvider,
  ComposerPrimitive,
  ErrorPrimitive,
  MessagePrimitive,
  ThreadPrimitive,
  useMessage,
  useMessagePartText,
  useLocalRuntime,
  useThread,
  type ChatModelAdapter,
  type ThreadMessage,
  type ThreadMessageLike,
} from '@assistant-ui/react';
import { StreamdownTextPrimitive } from '@assistant-ui/react-streamdown';
import { cjk } from '@streamdown/cjk';
import { code } from '@streamdown/code';
import { math } from '@streamdown/math';
import { mermaid } from '@streamdown/mermaid';
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
import { Bot, ChevronDown, FileText, Image as ImageIcon, LoaderCircle, MessageSquarePlus, Music2, Paperclip, Send, Sparkles, Video, X } from 'lucide-react';
import { Badge, Button, Select, Textarea } from '../components/ui';
import { GatewayApiError, createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, streamChatCompletionText, taskIsPending, uploadFileToStorage } from '../api';
import type { PlaygroundMode } from '../types';
import {
  defaultMediaGenerationSettings,
  deriveMediaModelCapabilities,
  gatewayTaskErrorText,
  mediaRequestPayload,
  MediaSettingsPopover,
  MediaTaskBoard,
  normalizeMediaSettingsForCapabilities,
  type MediaGenerationRun,
  type MediaGenerationSettings,
  type MediaModelCapabilities,
} from './playground-media';

type VideoCreateMode = 'text_to_video' | 'first_last_frame' | 'omni_reference';

const MEDIA_RUNS_STORAGE_KEY = 'easyai:playground:media-runs:v1';
const MEDIA_RUNS_STORAGE_LIMIT = 50;
const CHAT_MESSAGES_STORAGE_KEY = 'easyai:playground:chat-messages:v1';
const CHAT_MESSAGES_STORAGE_LIMIT = 100;

interface StoredChatMessage {
  content: string;
  createdAt: string;
  id: string;
  role: 'assistant' | 'user';
}

interface ModelOption {
  count: number;
  label: string;
  models: PlatformModel[];
  provider: string;
  value: string;
}

type PlaygroundUploadKind = 'audio' | 'file' | 'image' | 'video';

interface PlaygroundUpload {
  contentType: string;
  id: string;
  kind: PlaygroundUploadKind;
  name: string;
  raw: Record<string, unknown>;
  size: number;
  url: string;
}

const modeOptions: Array<{ description: string; icon: ReactNode; label: string; value: PlaygroundMode }> = [
  { value: 'chat', label: '大模型对话', description: '对话、推理、结构化输出', icon: <Bot size={16} /> },
  { value: 'image', label: '图像生成', description: '文生图、图像编辑参数预览', icon: <ImageIcon size={16} /> },
  { value: 'video', label: '视频生成', description: '图生视频、文生视频任务测试', icon: <Video size={16} /> },
];

const videoModeOptions: Array<{ label: string; value: VideoCreateMode }> = [
  { value: 'text_to_video', label: '文生视频' },
  { value: 'first_last_frame', label: '首尾帧' },
  { value: 'omni_reference', label: '全能参考' },
];

const placeholderByMode: Record<PlaygroundMode, string> = {
  chat: '输入问题、角色设定或测试提示词，支持 OpenAI 兼容格式验证...',
  image: '描述你想生成的画面，例如：未来城市中的玻璃温室，晨光，电影级构图...',
  video: '描述视频镜头、主体运动和风格，例如：低角度跟拍，一辆复古跑车穿过雨夜街道...',
};

const quickPrompts: Record<PlaygroundMode, string[]> = {
  chat: ['写一个产品发布摘要', '生成接口调用示例', '分析失败重试策略'],
  image: ['产品海报', '角色设定图', '电商主图'],
  video: ['5 秒运镜', '首帧转视频', '宣传短片'],
};

const mediaUploadAccept = 'image/*,video/*,audio/*';
const chatUploadAccept = [
  mediaUploadAccept,
  '.csv',
  '.doc',
  '.docx',
  '.json',
  '.jsonl',
  '.md',
  '.markdown',
  '.pdf',
  '.ppt',
  '.pptx',
  '.txt',
  '.xls',
  '.xlsx',
  '.yaml',
  '.yml',
  'application/json',
  'application/msword',
  'application/pdf',
  'application/vnd.ms-excel',
  'application/vnd.ms-powerpoint',
  'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  'text/*',
].join(',');

const publicWorks = [
  { title: '雨夜霓虹街区', type: '图像生成', image: 'https://picsum.photos/seed/easyai-neon-city/720/960' },
  { title: '玻璃温室晨光', type: '图像生成', image: 'https://picsum.photos/seed/easyai-glasshouse/720/540' },
  { title: '山谷航拍镜头', type: '视频生成', image: 'https://picsum.photos/seed/easyai-valley-flight/720/1040' },
  { title: '极简产品广告', type: '图像生成', image: 'https://picsum.photos/seed/easyai-product-ad/720/680' },
  { title: '机械结构设定', type: '图像编辑', image: 'https://picsum.photos/seed/easyai-mecha-sketch/720/920' },
  { title: '城市模型推演', type: '大模型', image: 'https://picsum.photos/seed/easyai-city-plan/720/620' },
  { title: '海边人物电影感', type: '图像生成', image: 'https://picsum.photos/seed/easyai-cinematic-sea/720/980' },
  { title: '空间站漫游', type: '视频生成', image: 'https://picsum.photos/seed/easyai-orbital-walk/720/860' },
  { title: '水彩建筑手稿', type: '图像生成', image: 'https://picsum.photos/seed/easyai-watercolor-arch/720/760' },
  { title: '品牌 KV 探索', type: '图像生成', image: 'https://picsum.photos/seed/easyai-brand-kv/720/560' },
  { title: '古城夜游镜头', type: '视频生成', image: 'https://picsum.photos/seed/easyai-night-town/720/1020' },
  { title: '界面概念板', type: '大模型', image: 'https://picsum.photos/seed/easyai-ui-board/720/650' },
];

const streamdownPlugins = { cjk, code, math, mermaid };

export function PlaygroundPage(props: {
  apiKeySecretsById: Record<string, string>;
  apiKeys: GatewayApiKey[];
  mode: PlaygroundMode;
  models: PlatformModel[];
  selectedApiKeyId: string;
  token: string;
  onApiKeyChange: (apiKeyId: string) => void;
  onCreateApiKey: () => void;
  onLogin: () => void;
  onModeChange: (mode: PlaygroundMode) => void;
}) {
  const [prompt, setPrompt] = useState('');
  const [selectedModel, setSelectedModel] = useState('');
  const [imageHasReference, setImageHasReference] = useState(false);
  const [videoMode, setVideoMode] = useState<VideoCreateMode>('text_to_video');
  const [threadKey, setThreadKey] = useState(0);
  const [mediaSettings, setMediaSettings] = useState(defaultMediaGenerationSettings);
  const [mediaRuns, setMediaRuns] = useState<MediaGenerationRun[]>(readStoredMediaRuns);
  const [mediaMessage, setMediaMessage] = useState('');
  const [mediaUploadMessage, setMediaUploadMessage] = useState('');
  const [mediaUploads, setMediaUploads] = useState<PlaygroundUpload[]>([]);
  const [mediaUploading, setMediaUploading] = useState(false);
  const isMountedRef = useRef(false);
  const pendingMediaModelRef = useRef('');
  const resumedTaskIdsRef = useRef(new Set<string>());
  const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
  const effectiveImageHasReference = imageHasReference || (props.mode === 'image' && mediaUploads.some((item) => item.kind === 'image'));
  const modelOptions = useMemo(
    () => buildModelOptions(filterModelsForMode(props.models, props.mode, effectiveImageHasReference, videoMode)),
    [effectiveImageHasReference, props.mode, props.models, videoMode],
  );
  const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
  const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : '';
  const activeModelOption = useMemo(() => modelOptions.find((item) => item.value === selectedModel), [modelOptions, selectedModel]);
  const mediaCapabilities = useMemo(
    () => props.mode === 'chat'
      ? undefined
      : activeModelOption
        ? deriveMediaModelCapabilities(activeModelOption.models, props.mode, videoMode, mediaSettings.resolution)
        : undefined,
    [activeModelOption, mediaSettings.resolution, props.mode, videoMode],
  );

  useEffect(() => {
    setSelectedModel((current) => {
      const pendingModel = pendingMediaModelRef.current;
      if (pendingModel) {
        const resolvedPending = resolveModelOptionValue(pendingModel, modelOptions);
        if (resolvedPending) {
          pendingMediaModelRef.current = '';
          return resolvedPending;
        }
      }
      return modelOptions.some((item) => item.value === current) ? current : modelOptions[0]?.value ?? '';
    });
  }, [modelOptions]);

  useEffect(() => {
    isMountedRef.current = true;
    return () => {
      isMountedRef.current = false;
    };
  }, []);

  useEffect(() => {
    if (props.mode === 'chat' || !mediaCapabilities) return;
    const mediaMode = props.mode;
    setMediaSettings((current) => normalizeMediaSettingsForCapabilities(current, mediaCapabilities, mediaMode));
  }, [mediaCapabilities, props.mode]);

  useEffect(() => {
    writeStoredMediaRuns(mediaRuns);
  }, [mediaRuns]);

  async function uploadMediaFiles(files: File[]) {
    if (!files.length) return;
    const credential = activeApiKeySecret || props.token;
    if (!props.token) {
      props.onLogin();
      return;
    }
    if (!credential) {
      setMediaUploadMessage('请选择可用于测试的 API Key 后再上传。');
      return;
    }
    setMediaUploading(true);
    setMediaUploadMessage('');
    try {
      const { items, warnings } = await uploadPlaygroundFiles(credential, files, {
        allowFiles: false,
        source: `ai-gateway-playground-${props.mode}`,
      });
      if (items.length) {
        setMediaUploads((current) => [...current, ...items]);
        if (props.mode === 'image' && items.some((item) => item.kind === 'image')) {
          setImageHasReference(true);
        }
      }
      setMediaUploadMessage(warnings[0] ?? (items.length ? `已上传 ${items.length} 个参考素材。` : ''));
    } catch (err) {
      setMediaUploadMessage(err instanceof Error ? err.message : '文件上传失败');
    } finally {
      setMediaUploading(false);
    }
  }

  useEffect(() => {
    const credential = activeApiKeySecret || props.token;
    if (!credential) return;
    const resumableRuns = mediaRuns.filter((run) => (
      run.task?.id
      && taskIsPending(run.status)
      && !resumedTaskIdsRef.current.has(run.task.id)
    ));
    resumableRuns.forEach((run) => {
      if (!run.task?.id) return;
      resumedTaskIdsRef.current.add(run.task.id);
      void pollTaskUntilSettled(credential, run.task, {
        onUpdate: (detail) => updateMediaRunFromTask(run.localId, detail),
      })
        .then((detail) => {
          if (!isMountedRef.current) return;
          updateMediaRunFromTask(run.localId, detail);
        })
        .catch((err) => {
          if (!isMountedRef.current) return;
          const errorMessage = err instanceof Error ? err.message : '任务状态同步失败';
          setMediaRuns((current) => updateMediaRun(current, run.localId, { error: errorMessage, status: 'failed' }));
        });
    });
  }, [activeApiKeySecret, mediaRuns, props.token]);

  async function submitMediaTask(overrides?: {
    model?: string;
    mode?: Exclude<PlaygroundMode, 'chat'>;
    prompt?: string;
    settings?: MediaGenerationSettings;
  }) {
    const runMode = overrides?.mode ?? props.mode;
    if (runMode === 'chat') return;
    const sourceSettings = overrides?.settings ?? mediaSettings;
    const runSettings = mediaCapabilities ? normalizeMediaSettingsForCapabilities(sourceSettings, mediaCapabilities, runMode) : sourceSettings;
    const trimmedPrompt = (overrides?.prompt ?? prompt).trim();
    const credential = activeApiKeySecret || props.token;
    if (!props.token) {
      props.onLogin();
      return;
    }
    if (!credential) {
      setMediaMessage('请选择可用于测试的 API Key；如果列表为空，请先创建一个 Key。');
      return;
    }
    const runModel = overrides?.model ?? selectedModel;
    if (!runModel) {
      setMediaMessage('当前没有可用模型，请确认用户组权限或平台模型配置。');
      return;
    }
    if (!trimmedPrompt) {
      setMediaMessage(runMode === 'video' ? '请输入视频提示词。' : '请输入图片提示词。');
      return;
    }

    const localId = newLocalId();
    const runUploads = overrides ? [] : mediaUploads;
    const modelLabel = modelOptions.find((item) => item.value === runModel)?.label ?? runModel;
    const run: MediaGenerationRun = {
      createdAt: new Date().toISOString(),
      localId,
      mode: runMode,
      modelLabel,
      modelValue: runModel,
      prompt: trimmedPrompt,
      settings: runSettings,
      status: 'submitting',
    };

    setMediaRuns((current) => [...current, run]);
    setMediaMessage('');
    try {
      const uploadPayload = mediaUploadRequestPayload(runUploads, runMode);
      const requestPayload = {
        model: runModel,
        prompt: promptWithUploadSummary(trimmedPrompt, runUploads),
        ...mediaRequestPayload(runSettings, runMode),
        ...uploadPayload,
      };
      const response = runMode === 'video'
        ? await createVideoGenerationTask(credential, requestPayload)
        : runUploads.some((item) => item.kind === 'image')
          ? await createImageEditTask(credential, requestPayload)
          : await createImageGenerationTask(credential, requestPayload);
      setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task }));
      if (!overrides) {
        setMediaUploads([]);
        setMediaUploadMessage('');
        setImageHasReference(false);
      }
      void pollMediaRunUntilSettled(credential, localId, response.task);
    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : '生成任务提交失败';
      setMediaMessage(errorMessage);
      setMediaRuns((current) => updateMediaRun(current, localId, { error: errorMessage, status: 'failed' }));
    }
  }

  async function pollMediaRunUntilSettled(credential: string, localId: string, task: GatewayTask) {
    try {
      const detail = await pollTaskUntilSettled(credential, task, {
        onUpdate: (nextTask) => updateMediaRunFromTask(localId, nextTask),
      });
      if (!isMountedRef.current) return;
      updateMediaRunFromTask(localId, detail);
    } catch (err) {
      if (!isMountedRef.current) return;
      const errorMessage = err instanceof Error ? err.message : '任务状态同步失败';
      setMediaRuns((current) => updateMediaRun(current, localId, { error: errorMessage, status: 'failed' }));
    }
  }

  function updateMediaRunFromTask(localId: string, task: GatewayTask) {
    if (!isMountedRef.current) return;
    setMediaRuns((current) => updateMediaRun(current, localId, {
      error: taskIsPending(task.status) ? '' : gatewayTaskErrorText(task, '任务执行失败'),
      status: task.status,
      task,
    }));
  }

  function selectMediaRunModel(run: MediaGenerationRun) {
    const runModel = resolveMediaRunModelValue(run, modelOptions);
    if (runModel) {
      pendingMediaModelRef.current = '';
      setSelectedModel(runModel);
      return runModel;
    }
    const fallbackModel = firstString(run.modelValue, run.task?.requestedModel, taskRequestModel(run.task), run.task?.model, run.task?.resolvedModel);
    pendingMediaModelRef.current = fallbackModel;
    return fallbackModel;
  }

  function editMediaRun(run: MediaGenerationRun) {
    setPrompt(run.prompt);
    setMediaSettings(run.settings);
    selectMediaRunModel(run);
    if (props.mode !== run.mode) {
      props.onModeChange(run.mode);
    }
    setMediaMessage('已带入这条任务的模型、提示词和参数，可调整后再次生成。');
  }

  function rerunMediaRun(run: MediaGenerationRun) {
    setPrompt(run.prompt);
    setMediaSettings(run.settings);
    const runModel = selectMediaRunModel(run);
    if (props.mode !== run.mode) {
      props.onModeChange(run.mode);
      setMediaMessage('已切换到对应模式并带入模型和参数，请确认后再次生成。');
      return;
    }
    void submitMediaTask({ mode: run.mode, model: runModel, prompt: run.prompt, settings: run.settings });
  }

  const mediaComposer = props.mode === 'chat' ? null : (
    <Composer
      apiKeySecretsById={props.apiKeySecretsById}
      apiKeys={props.apiKeys}
      mode={props.mode}
      modelOptions={modelOptions}
      prompt={prompt}
      selectedApiKeyId={activeApiKeyId}
      selectedModel={selectedModel}
      imageHasReference={effectiveImageHasReference}
      mediaSettings={mediaSettings}
      mediaCapabilities={mediaCapabilities}
      uploadAccept={mediaUploadAccept}
      uploadMessage={mediaUploadMessage}
      uploads={mediaUploads}
      uploading={mediaUploading}
      videoMode={videoMode}
      onApiKeyChange={props.onApiKeyChange}
      onCreateApiKey={props.onCreateApiKey}
      onImageReferenceChange={setImageHasReference}
      onMediaSettingsChange={setMediaSettings}
      onModeChange={props.onModeChange}
      onModelChange={setSelectedModel}
      onPromptChange={setPrompt}
      onRemoveUpload={(id) => setMediaUploads((current) => {
        const next = current.filter((item) => item.id !== id);
        if (!next.some((item) => item.kind === 'image')) {
          setImageHasReference(false);
        }
        return next;
      })}
      onSubmit={() => void submitMediaTask()}
      onUploadFiles={(files) => void uploadMediaFiles(files)}
      onVideoModeChange={setVideoMode}
    />
  );

  function startNewThread() {
    clearStoredChatMessages();
    setThreadKey((value) => value + 1);
  }

  return (
    <div className="playgroundPage">
      <aside className="playgroundSidebar">
        <div className="playgroundSidebarTitle">
          <strong>开启创作</strong>
          <Badge variant="secondary">Test</Badge>
        </div>
        <button type="button" className="playgroundSideItem active" onClick={startNewThread}>
          <MessageSquarePlus size={15} />
          新对话
        </button>
        <button type="button" className="playgroundSideItem">
          <Sparkles size={15} />
          默认创作
        </button>
      </aside>

      <main className="playgroundStage">
        <section className="playgroundHero" data-chat={props.mode === 'chat'} data-media-board={props.mode !== 'chat' && mediaRuns.length > 0}>
          {props.mode === 'chat' ? (
            <AssistantChatPlayground
              key={threadKey}
              apiKeySecretsById={props.apiKeySecretsById}
              apiKeys={props.apiKeys}
              modelOptions={modelOptions}
              selectedApiKeyId={props.selectedApiKeyId}
              selectedModel={selectedModel}
              token={props.token}
              onApiKeyChange={props.onApiKeyChange}
              onCreateApiKey={props.onCreateApiKey}
              onModeChange={props.onModeChange}
              onModelChange={setSelectedModel}
              onLogin={props.onLogin}
            />
          ) : mediaRuns.length > 0 && mediaComposer ? (
            <MediaTaskBoard
              composer={mediaComposer}
              message={mediaMessage}
              runs={mediaRuns}
              onEditRun={editMediaRun}
              onRerun={rerunMediaRun}
            />
          ) : (
            <>
              <ModeSwitch activeMode={props.mode} onModeChange={props.onModeChange} />
              <PlaygroundGreeting activeMode={activeMode} />
              {mediaMessage && <p className="playgroundError">{mediaMessage}</p>}
              {mediaComposer}
            </>
          )}
        </section>
      </main>
    </div>
  );
}

export function PlaygroundEntry(props: {
  onModeChange: (mode: PlaygroundMode) => void;
}) {
  const [mode, setMode] = useState<PlaygroundMode>('chat');
  const [prompt, setPrompt] = useState('');
  const activeMode = modeOptions.find((item) => item.value === mode) ?? modeOptions[0];

  function openPlayground(nextMode = mode) {
    props.onModeChange(nextMode);
  }

  return (
    <section className="homePlaygroundEntry">
      <h2>
        开启你的 <button type="button" onClick={() => openPlayground(mode)}>{activeMode.label}<ChevronDown size={20} /></button> 即刻测试
      </h2>
      <Composer
        mode={mode}
        modelOptions={[]}
        prompt={prompt}
        compact
        onModeChange={(nextMode) => {
          setMode(nextMode);
          openPlayground(nextMode);
        }}
        onModelChange={() => undefined}
        onPromptChange={setPrompt}
        onSubmit={() => openPlayground(mode)}
      />
      <div className="playgroundModeCards">
        {modeOptions.map((item) => (
          <button type="button" key={item.value} onClick={() => openPlayground(item.value)}>
            <span>{item.icon}</span>
            <strong>{item.label}</strong>
            <small>{item.description}</small>
          </button>
        ))}
      </div>
    </section>
  );
}

export function PublicWorksGallery() {
  return (
    <section className="publicWorksSection">
      <div className="publicWorksHeader">
        <div>
          <p className="eyebrow">Community Gallery</p>
          <h2>公开作品展示</h2>
        </div>
        <div className="publicWorksTabs" aria-label="作品类型">
          <button type="button" data-active="true">发现</button>
          <button type="button">图片</button>
          <button type="button">视频</button>
        </div>
      </div>
      <div className="publicWorksMasonry">
        {publicWorks.map((item) => (
          <article className="publicWorkCard" key={item.title}>
            <img src={item.image} alt={item.title} loading="lazy" />
            <div>
              <Badge variant="secondary">{item.type}</Badge>
              <strong>{item.title}</strong>
            </div>
          </article>
        ))}
      </div>
    </section>
  );
}

function AssistantChatPlayground(props: {
  apiKeySecretsById: Record<string, string>;
  apiKeys: GatewayApiKey[];
  modelOptions: ModelOption[];
  selectedApiKeyId: string;
  selectedModel: string;
  token: string;
  onApiKeyChange: (apiKeyId: string) => void;
  onCreateApiKey: () => void;
  onLogin: () => void;
  onModeChange: (mode: PlaygroundMode) => void;
  onModelChange: (value: string) => void;
}) {
  const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
  const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : '';
  const canRun = Boolean(props.token && props.selectedModel && activeApiKeySecret);
  const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById);
  const initialMessages = useMemo(() => readStoredChatMessages(), []);
  const [chatUploadMessage, setChatUploadMessage] = useState('');
  const [chatUploads, setChatUploads] = useState<PlaygroundUpload[]>([]);
  const [chatUploading, setChatUploading] = useState(false);
  const chatUploadsRef = useRef(chatUploads);
  useEffect(() => {
    chatUploadsRef.current = chatUploads;
  }, [chatUploads]);

  async function uploadChatFiles(files: File[]) {
    if (!files.length) return;
    if (!props.token) {
      props.onLogin();
      return;
    }
    if (!activeApiKeySecret) {
      setChatUploadMessage('请选择可用于测试的 API Key 后再上传。');
      return;
    }
    setChatUploading(true);
    setChatUploadMessage('');
    try {
      const { items, warnings } = await uploadPlaygroundFiles(activeApiKeySecret, files, {
        allowFiles: true,
        source: 'ai-gateway-playground-chat',
      });
      if (items.length) {
        setChatUploads((current) => [...current, ...items]);
      }
      setChatUploadMessage(warnings[0] ?? (items.length ? `已上传 ${items.length} 个附件。` : ''));
    } catch (err) {
      setChatUploadMessage(err instanceof Error ? err.message : '文件上传失败');
    } finally {
      setChatUploading(false);
    }
  }

  const adapter = useMemo<ChatModelAdapter>(() => ({
    async *run({ abortSignal, messages }) {
      if (!props.token) {
        props.onLogin();
        throw new GatewayApiError('请先登录后再测试模型。');
      }
      if (!activeApiKeySecret) {
        throw new GatewayApiError('请选择可用于测试的 API Key；如果列表为空，请刷新或重新创建一个 Key。');
      }
      if (!props.selectedModel) {
        throw new GatewayApiError('当前没有可用的大模型，请确认用户组权限或平台模型配置。');
      }
      const requestUploads = chatUploadsRef.current;
      if (requestUploads.length) {
        chatUploadsRef.current = [];
        setChatUploads([]);
        setChatUploadMessage('');
      }
      let text = '';
      for await (const delta of streamChatCompletionText(
        activeApiKeySecret,
        {
          messages: toGatewayChatMessages(messages, requestUploads),
          model: props.selectedModel,
        },
        abortSignal,
      )) {
        text += delta;
        yield {
          content: [{ type: 'text', text }],
        };
      }
      yield {
        content: [{ type: 'text', text }],
        status: { type: 'complete', reason: 'stop' },
      };
    },
  }), [activeApiKeySecret, props]);
  const runtime = useLocalRuntime(adapter, { initialMessages });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      <AssistantChatPersistenceBridge />
      <ThreadPrimitive.Root className="assistantThreadRoot">
        <ThreadPrimitive.Empty>
          <div className="assistantEmptyStage">
            <AssistantEmptyState
              apiKeyNotice={apiKeyNotice}
              apiKeySecretsById={props.apiKeySecretsById}
              apiKeys={props.apiKeys}
              canRun={canRun}
              modelOptions={props.modelOptions}
              selectedApiKeyId={activeApiKeyId}
              selectedModel={props.selectedModel}
              token={props.token}
              activeApiKeySecret={activeApiKeySecret}
              uploadAccept={chatUploadAccept}
              uploadMessage={chatUploadMessage}
              uploads={chatUploads}
              uploading={chatUploading}
              onApiKeyChange={props.onApiKeyChange}
              onCreateApiKey={props.onCreateApiKey}
              onModeChange={props.onModeChange}
              onModelChange={props.onModelChange}
              onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
              onUploadFiles={(files) => void uploadChatFiles(files)}
            />
          </div>
        </ThreadPrimitive.Empty>
        <ThreadPrimitive.If empty={false}>
          <div className="assistantShell" data-has-notice={Boolean(apiKeyNotice)}>
            {apiKeyNotice && (
              <div className="assistantApiKeyNotice">
                <span>{apiKeyNotice}</span>
                <Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
                  去创建 API Key
                </Button>
              </div>
            )}
            <ThreadPrimitive.Viewport className="assistantThreadViewport">
              <div className="assistantMessageList">
                <ThreadPrimitive.Messages components={{ Message: AssistantMessage }} />
              </div>
              <ThreadPrimitive.ViewportFooter className="assistantComposerDock">
                <AssistantChatComposer
                  apiKeyNotice={apiKeyNotice}
                  apiKeySecretsById={props.apiKeySecretsById}
                  apiKeys={props.apiKeys}
                  canRun={canRun}
                  docked
                  modelOptions={props.modelOptions}
                  placeholder={assistantPlaceholder(props.token, props.selectedModel, activeApiKeySecret)}
                  selectedApiKeyId={activeApiKeyId}
                  selectedModel={props.selectedModel}
                  uploadAccept={chatUploadAccept}
                  uploadMessage={chatUploadMessage}
                  uploads={chatUploads}
                  uploading={chatUploading}
                  onApiKeyChange={props.onApiKeyChange}
                  onCreateApiKey={props.onCreateApiKey}
                  onModeChange={props.onModeChange}
                  onModelChange={props.onModelChange}
                  onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
                  onUploadFiles={(files) => void uploadChatFiles(files)}
                />
              </ThreadPrimitive.ViewportFooter>
            </ThreadPrimitive.Viewport>
          </div>
        </ThreadPrimitive.If>
      </ThreadPrimitive.Root>
    </AssistantRuntimeProvider>
  );
}

function AssistantChatPersistenceBridge() {
  const messages = useThread((state) => state.messages);
  const skipInitialEmptyWriteRef = useRef(true);

  useEffect(() => {
    if (skipInitialEmptyWriteRef.current) {
      skipInitialEmptyWriteRef.current = false;
      if (!messages.length && hasStoredChatMessages()) return;
    }
    writeStoredChatMessages(messages);
  }, [messages]);

  return null;
}

function ModeSwitch(props: {
  activeMode: PlaygroundMode;
  onModeChange: (mode: PlaygroundMode) => void;
}) {
  return (
    <div className="playgroundModeSwitch">
      {modeOptions.map((item) => (
        <button
          type="button"
          key={item.value}
          data-active={props.activeMode === item.value}
          onClick={() => props.onModeChange(item.value)}
        >
          {item.icon}
          <span>{item.label}</span>
        </button>
      ))}
    </div>
  );
}

function PlaygroundGreeting(props: {
  activeMode: { description: string; label: string };
}) {
  return (
    <div className="playgroundGreeting">
      <span>你好，想测试什么？</span>
      <strong>{props.activeMode.label}</strong>
      <small>{props.activeMode.description}</small>
    </div>
  );
}

function AssistantEmptyState(props: {
  activeApiKeySecret: string;
  apiKeyNotice: string;
  apiKeySecretsById: Record<string, string>;
  apiKeys: GatewayApiKey[];
  canRun: boolean;
  modelOptions: ModelOption[];
  selectedApiKeyId: string;
  selectedModel: string;
  token: string;
  uploadAccept: string;
  uploadMessage: string;
  uploads: PlaygroundUpload[];
  uploading: boolean;
  onApiKeyChange: (apiKeyId: string) => void;
  onCreateApiKey: () => void;
  onModeChange: (mode: PlaygroundMode) => void;
  onModelChange: (value: string) => void;
  onRemoveUpload: (id: string) => void;
  onUploadFiles: (files: File[]) => void;
}) {
  const activeMode = modeOptions.find((item) => item.value === 'chat') ?? modeOptions[0];
  const placeholder = props.canRun ? placeholderByMode.chat : assistantPlaceholder(props.token, props.selectedModel, props.activeApiKeySecret);

  return (
    <div className="assistantEmpty">
      <ModeSwitch activeMode="chat" onModeChange={props.onModeChange} />
      <PlaygroundGreeting activeMode={activeMode} />
      <AssistantChatComposer
        apiKeyNotice={props.apiKeyNotice}
        apiKeySecretsById={props.apiKeySecretsById}
        apiKeys={props.apiKeys}
        canRun={props.canRun}
        modelOptions={props.modelOptions}
        placeholder={placeholder}
        selectedApiKeyId={props.selectedApiKeyId}
        selectedModel={props.selectedModel}
        uploadAccept={props.uploadAccept}
        uploadMessage={props.uploadMessage}
        uploads={props.uploads}
        uploading={props.uploading}
        onApiKeyChange={props.onApiKeyChange}
        onCreateApiKey={props.onCreateApiKey}
        onModeChange={props.onModeChange}
        onModelChange={props.onModelChange}
        onRemoveUpload={props.onRemoveUpload}
        onUploadFiles={props.onUploadFiles}
      />
    </div>
  );
}

function AssistantChatComposer(props: {
  apiKeyNotice: string;
  apiKeySecretsById: Record<string, string>;
  apiKeys: GatewayApiKey[];
  canRun: boolean;
  docked?: boolean;
  modelOptions: ModelOption[];
  placeholder: string;
  selectedApiKeyId: string;
  selectedModel: string;
  uploadAccept?: string;
  uploadMessage?: string;
  uploads?: PlaygroundUpload[];
  uploading?: boolean;
  onApiKeyChange: (apiKeyId: string) => void;
  onCreateApiKey: () => void;
  onModeChange: (mode: PlaygroundMode) => void;
  onModelChange: (value: string) => void;
  onRemoveUpload?: (id: string) => void;
  onUploadFiles?: (files: File[]) => void;
}) {
  const className = ['playgroundComposer', 'assistantChatComposer', props.docked ? 'assistantDockComposer' : 'assistantEmptyComposer'].join(' ');

  return (
    <ComposerPrimitive.Root className={className}>
      <div className="composerBody">
        <ComposerUploadButton
          accept={props.uploadAccept ?? chatUploadAccept}
          active={Boolean(props.uploads?.length)}
          disabled={!props.canRun || !props.onUploadFiles}
          uploading={props.uploading}
          onFiles={props.onUploadFiles}
        />
        <div className="composerInputStack">
          <ComposerPrimitive.Input
            className="assistantEmptyInput"
            disabled={!props.canRun}
            placeholder={props.placeholder}
          />
          <UploadAttachmentList
            message={props.uploadMessage}
            uploads={props.uploads ?? []}
            onRemove={props.onRemoveUpload}
          />
        </div>
      </div>
      <div className="composerFooter">
        <Select value="chat" onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
          {modeOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
        </Select>
        <Select
          className="playgroundModelSelect"
          value={props.selectedModel}
          disabled={!props.modelOptions.length}
          onChange={(event) => props.onModelChange(event.target.value)}
        >
          {props.modelOptions.length ? props.modelOptions.map((item) => (
            <option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
          )) : <option value="">没有可用模型</option>}
        </Select>
        <ApiKeySelect
          apiKeySecretsById={props.apiKeySecretsById}
          apiKeys={props.apiKeys}
          selectedApiKeyId={props.selectedApiKeyId}
          onApiKeyChange={props.onApiKeyChange}
        />
        {props.apiKeyNotice && (
          <Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
            创建 API Key
          </Button>
        )}
        <ComposerPrimitive.Send className="composerSendButton" disabled={!props.canRun} aria-label="发送消息">
          <Send size={18} />
        </ComposerPrimitive.Send>
      </div>
    </ComposerPrimitive.Root>
  );
}

function AssistantMessage() {
  const hasError = useMessage((state) => state.status?.type === 'incomplete' && state.status.reason === 'error');

  return (
    <MessagePrimitive.Root className="assistantMessage">
      <MessagePrimitive.If user>
        <div className="assistantBubble user">
          <MessagePrimitive.Parts components={{ Text: PlainMessageText }} />
        </div>
      </MessagePrimitive.If>
      <MessagePrimitive.If assistant>
        <div className={hasError ? 'assistantBubble assistant error' : 'assistantBubble assistant'}>
          <MessagePrimitive.Parts components={{ Text: AssistantMarkdownText }} />
          <MessagePrimitive.Error>
            <strong>调用失败</strong>
            <ErrorPrimitive.Message className="assistantErrorMessage" />
          </MessagePrimitive.Error>
          {!hasError && (
            <MessagePrimitive.If hasContent={false}>
              <span className="assistantTyping">模型正在回复...</span>
            </MessagePrimitive.If>
          )}
        </div>
      </MessagePrimitive.If>
    </MessagePrimitive.Root>
  );
}

function PlainMessageText() {
  const { text } = useMessagePartText();
  return <span className="assistantPlainText">{text}</span>;
}

function AssistantMarkdownText() {
  return (
    <StreamdownTextPrimitive
      containerClassName="assistantMarkdown"
      plugins={streamdownPlugins}
      shikiTheme={['github-light', 'github-dark']}
    />
  );
}

function toGatewayChatMessages(messages: readonly ThreadMessage[], uploads: PlaygroundUpload[] = []) {
  const gatewayMessages = messages
    .filter((message) => message.role === 'user' || message.role === 'assistant')
    .map((message) => ({
      content: threadMessageText(message),
      role: message.role,
    }))
    .filter((message) => message.content.trim().length > 0);
  let lastUserIndex = -1;
  gatewayMessages.forEach((message, index) => {
    if (message.role === 'user') lastUserIndex = index;
  });
  if (lastUserIndex >= 0 && uploads.length) {
    gatewayMessages[lastUserIndex] = {
      ...gatewayMessages[lastUserIndex],
      content: promptWithUploadSummary(gatewayMessages[lastUserIndex].content, uploads),
    };
  }
  return gatewayMessages;
}

function threadMessageText(message: ThreadMessage) {
  return message.content
    .map((part) => part.type === 'text' ? part.text : '')
    .join('')
    .trim();
}

function readStoredChatMessages(): ThreadMessageLike[] {
  if (typeof window === 'undefined') return [];
  try {
    const raw = window.localStorage.getItem(CHAT_MESSAGES_STORAGE_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw) as unknown;
    const record = recordFromUnknown(parsed);
    const source = Array.isArray(parsed) ? parsed : record?.messages;
    if (!Array.isArray(source)) return [];
    return source
      .map(chatMessageLikeFromStorage)
      .filter((item): item is ThreadMessageLike => Boolean(item))
      .slice(-CHAT_MESSAGES_STORAGE_LIMIT);
  } catch {
    return [];
  }
}

function hasStoredChatMessages() {
  if (typeof window === 'undefined') return false;
  try {
    return Boolean(window.localStorage.getItem(CHAT_MESSAGES_STORAGE_KEY));
  } catch {
    return false;
  }
}

function writeStoredChatMessages(messages: readonly ThreadMessage[]) {
  if (typeof window === 'undefined') return;
  try {
    const storedMessages = messages
      .map(storedChatMessageFromThread)
      .filter((item): item is StoredChatMessage => Boolean(item))
      .slice(-CHAT_MESSAGES_STORAGE_LIMIT);
    if (!storedMessages.length) {
      window.localStorage.removeItem(CHAT_MESSAGES_STORAGE_KEY);
      return;
    }
    window.localStorage.setItem(CHAT_MESSAGES_STORAGE_KEY, JSON.stringify({
      messages: storedMessages,
      version: 1,
    }));
  } catch {
    // Best effort only: local chat history should not block sending messages.
  }
}

function clearStoredChatMessages() {
  if (typeof window === 'undefined') return;
  try {
    window.localStorage.removeItem(CHAT_MESSAGES_STORAGE_KEY);
  } catch {
    // Ignore storage errors.
  }
}

function chatMessageLikeFromStorage(value: unknown): ThreadMessageLike | undefined {
  const record = recordFromUnknown(value);
  if (!record) return undefined;
  const role = record.role === 'assistant' || record.role === 'user' ? record.role : undefined;
  const content = stringFromUnknown(record.content);
  if (!role || !content) return undefined;
  const createdAt = dateStringFromUnknown(record.createdAt);
  return {
    content,
    createdAt: createdAt ? new Date(createdAt) : undefined,
    id: stringFromUnknown(record.id) || undefined,
    role,
    status: role === 'assistant' ? { type: 'complete', reason: 'stop' } : undefined,
  };
}

function storedChatMessageFromThread(message: ThreadMessage): StoredChatMessage | undefined {
  if (message.role !== 'assistant' && message.role !== 'user') return undefined;
  const content = threadMessageText(message);
  if (!content) return undefined;
  return {
    content,
    createdAt: message.createdAt.toISOString(),
    id: message.id,
    role: message.role,
  };
}

function resolveSelectedApiKeyId(apiKeys: GatewayApiKey[], secretsById: Record<string, string>, selectedApiKeyId: string) {
  if (selectedApiKeyId && secretsById[selectedApiKeyId]) return selectedApiKeyId;
  const firstUsable = apiKeys.find((item) => Boolean(secretsById[item.id]));
  return firstUsable?.id ?? '';
}

function apiKeyNoticeText(apiKeys: GatewayApiKey[], secretsById: Record<string, string>) {
  if (!apiKeys.length) return '当前账号还没有可用 API Key，请先创建一个 Key。';
  if (!apiKeys.some((item) => Boolean(secretsById[item.id]))) {
    return '当前没有可用于在线测试的完整 API Key，请重新加载或创建一个 Key。';
  }
  return '';
}

function assistantPlaceholder(token: string, selectedModel: string, apiKeySecret: string) {
  if (!token) return '请先登录后再测试模型';
  if (!apiKeySecret) return '请选择可用于测试的 API Key';
  if (!selectedModel) return '当前没有可用模型';
  return '输入消息，Enter 发送，Shift + Enter 换行';
}

function Composer(props: {
  apiKeySecretsById?: Record<string, string>;
  apiKeys?: GatewayApiKey[];
  compact?: boolean;
  imageHasReference?: boolean;
  mediaCapabilities?: MediaModelCapabilities;
  mediaSettings?: MediaGenerationSettings;
  mode: PlaygroundMode;
  modelOptions: ModelOption[];
  prompt: string;
  selectedApiKeyId?: string;
  selectedModel?: string;
  uploadAccept?: string;
  uploadMessage?: string;
  uploads?: PlaygroundUpload[];
  uploading?: boolean;
  videoMode?: VideoCreateMode;
  onApiKeyChange?: (apiKeyId: string) => void;
  onCreateApiKey?: () => void;
  onImageReferenceChange?: (value: boolean) => void;
  onMediaSettingsChange?: (settings: MediaGenerationSettings) => void;
  onModeChange: (mode: PlaygroundMode) => void;
  onModelChange: (value: string) => void;
  onPromptChange: (value: string) => void;
  onRemoveUpload?: (id: string) => void;
  onSubmit?: () => void;
  onUploadFiles?: (files: File[]) => void;
  onVideoModeChange?: (value: VideoCreateMode) => void;
}) {
  const quickItems = quickPrompts[props.mode];
  const apiKeyNotice = props.apiKeys && props.apiKeySecretsById ? apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById) : '';
  return (
    <div className={props.compact ? 'playgroundComposer compact' : 'playgroundComposer'}>
      <div className="composerBody">
        <ComposerUploadButton
          accept={props.uploadAccept ?? mediaUploadAccept}
          active={Boolean(props.uploads?.length) || props.imageHasReference === true}
          disabled={!props.onUploadFiles}
          uploading={props.uploading}
          onFiles={props.onUploadFiles}
        />
        <div className="composerInputStack">
          <Textarea
            size={props.compact ? 'sm' : 'md'}
            value={props.prompt}
            placeholder={placeholderByMode[props.mode]}
            onChange={(event) => props.onPromptChange(event.target.value)}
          />
          <UploadAttachmentList
            message={props.uploadMessage}
            uploads={props.uploads ?? []}
            onRemove={props.onRemoveUpload}
          />
        </div>
      </div>
      <div className="composerFooter">
        <Select value={props.mode} onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
          {modeOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
        </Select>
        {props.mode === 'video' && (
          <Select value={props.videoMode ?? 'text_to_video'} onChange={(event) => props.onVideoModeChange?.(event.target.value as VideoCreateMode)}>
            {videoModeOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
          </Select>
        )}
        <Select className="playgroundModelSelect" value={props.selectedModel ?? ''} disabled={!props.modelOptions.length} onChange={(event) => props.onModelChange(event.target.value)}>
          {props.modelOptions.length ? props.modelOptions.map((item) => (
            <option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
          )) : <option value="">模型选择</option>}
        </Select>
        {props.mode !== 'chat' && props.mediaSettings && props.onMediaSettingsChange && (
          <MediaSettingsPopover
            capabilities={props.mediaCapabilities}
            mode={props.mode}
            settings={props.mediaSettings}
            onChange={props.onMediaSettingsChange}
          />
        )}
        {props.apiKeys && props.apiKeySecretsById && props.onApiKeyChange && (
          <ApiKeySelect
            apiKeySecretsById={props.apiKeySecretsById}
            apiKeys={props.apiKeys}
            selectedApiKeyId={props.selectedApiKeyId ?? ''}
            onApiKeyChange={props.onApiKeyChange}
          />
        )}
        {apiKeyNotice && props.onCreateApiKey && (
          <Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
            创建 API Key
          </Button>
        )}
        <div className="composerQuickPrompts">
          {quickItems.map((item) => <button type="button" key={item} onClick={() => props.onPromptChange(item)}>{item}</button>)}
        </div>
        <Button type="button" size="icon" aria-label="发送测试" onClick={props.onSubmit}>
          <Send size={15} />
        </Button>
      </div>
    </div>
  );
}

function ComposerUploadButton(props: {
  accept: string;
  active?: boolean;
  disabled?: boolean;
  uploading?: boolean;
  onFiles?: (files: File[]) => void;
}) {
  const inputRef = useRef<HTMLInputElement>(null);
  const disabled = props.disabled || props.uploading;

  return (
    <>
      <button
        type="button"
        className="composerUpload"
        aria-label="上传附件"
        data-active={props.active === true}
        disabled={disabled}
        onClick={() => inputRef.current?.click()}
      >
        {props.uploading ? <LoaderCircle className="composerUploadSpinner" size={18} /> : <Paperclip size={18} />}
      </button>
      <input
        ref={inputRef}
        type="file"
        multiple
        hidden
        accept={props.accept}
        disabled={disabled}
        onChange={(event) => {
          const files = Array.from(event.currentTarget.files ?? []);
          event.currentTarget.value = '';
          props.onFiles?.(files);
        }}
      />
    </>
  );
}

function UploadAttachmentList(props: {
  message?: string;
  uploads: PlaygroundUpload[];
  onRemove?: (id: string) => void;
}) {
  if (!props.uploads.length && !props.message) return null;
  return (
    <div className="composerUploadArea">
      {props.uploads.length > 0 && (
        <div className="composerUploadList">
          {props.uploads.map((item) => (
            <span className="composerUploadChip" key={item.id} title={`${item.name} · ${item.url}`}>
              {uploadKindIcon(item.kind)}
              <span>{item.name}</span>
              <small>{formatFileSize(item.size)}</small>
              {props.onRemove && (
                <button type="button" aria-label={`移除 ${item.name}`} onClick={() => props.onRemove?.(item.id)}>
                  <X size={13} />
                </button>
              )}
            </span>
          ))}
        </div>
      )}
      {props.message && <div className="composerUploadMessage">{props.message}</div>}
    </div>
  );
}

function uploadKindIcon(kind: PlaygroundUploadKind) {
  if (kind === 'image') return <ImageIcon size={14} />;
  if (kind === 'video') return <Video size={14} />;
  if (kind === 'audio') return <Music2 size={14} />;
  return <FileText size={14} />;
}

function ApiKeySelect(props: {
  apiKeySecretsById: Record<string, string>;
  apiKeys: GatewayApiKey[];
  selectedApiKeyId: string;
  onApiKeyChange: (apiKeyId: string) => void;
}) {
  const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
  return (
    <Select
      className="playgroundApiKeySelect"
      value={activeApiKeyId}
      disabled={!props.apiKeys.length}
      onChange={(event) => props.onApiKeyChange(event.target.value)}
    >
      {!activeApiKeyId && <option value="">{props.apiKeys.length ? '选择 API Key' : '暂无 API Key'}</option>}
      {props.apiKeys.map((item) => {
        const usable = Boolean(props.apiKeySecretsById[item.id]);
        return (
          <option value={item.id} key={item.id} disabled={!usable}>
            {item.name} · {item.keyPrefix}
          </option>
        );
      })}
    </Select>
  );
}

async function uploadPlaygroundFiles(
  token: string,
  files: File[],
  options: { allowFiles: boolean; source: string },
): Promise<{ items: PlaygroundUpload[]; warnings: string[] }> {
  const accepted: Array<{ file: File; kind: PlaygroundUploadKind }> = [];
  const warnings: string[] = [];
  files.forEach((file) => {
    const kind = acceptedUploadKind(file, options.allowFiles);
    if (!kind) {
      warnings.push(options.allowFiles
        ? `已跳过 ${file.name}，聊天仅支持图片、视频、音频和常见文档。`
        : `已跳过 ${file.name}，当前场景仅支持图片、视频和音频。`);
      return;
    }
    accepted.push({ file, kind });
  });
  if (!accepted.length) return { items: [], warnings };
  const items = await Promise.all(accepted.map(async ({ file, kind }) => {
    const response = await uploadFileToStorage(token, file, options.source);
    const url = uploadResponseUrl(response);
    if (!url) {
      throw new Error(`${file.name} 上传成功，但网关没有返回可用文件 URL。`);
    }
    return {
      contentType: file.type || '',
      id: newLocalId(),
      kind,
      name: file.name || '未命名文件',
      raw: response,
      size: file.size,
      url,
    };
  }));
  return { items, warnings };
}

function acceptedUploadKind(file: File, allowFiles: boolean): PlaygroundUploadKind | undefined {
  const mime = file.type.toLowerCase();
  const extension = fileExtension(file.name);
  if (mime.startsWith('image/') || imageExtensions.has(extension)) return 'image';
  if (mime.startsWith('video/') || videoExtensions.has(extension)) return 'video';
  if (mime.startsWith('audio/') || audioExtensions.has(extension)) return 'audio';
  if (allowFiles && (documentExtensions.has(extension) || documentMimes.has(mime))) return 'file';
  return undefined;
}

const imageExtensions = new Set(['avif', 'bmp', 'gif', 'heic', 'heif', 'jpeg', 'jpg', 'png', 'svg', 'tif', 'tiff', 'webp']);
const videoExtensions = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'webm']);
const audioExtensions = new Set(['aac', 'flac', 'm4a', 'mp3', 'oga', 'ogg', 'opus', 'wav', 'weba']);
const documentExtensions = new Set(['csv', 'doc', 'docx', 'json', 'jsonl', 'md', 'markdown', 'pdf', 'ppt', 'pptx', 'txt', 'xls', 'xlsx', 'yaml', 'yml']);
const documentMimes = new Set([
  'application/json',
  'application/msword',
  'application/pdf',
  'application/vnd.ms-excel',
  'application/vnd.ms-powerpoint',
  'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  'text/csv',
  'text/markdown',
  'text/plain',
  'text/yaml',
]);

function fileExtension(name: string) {
  const index = name.lastIndexOf('.');
  return index >= 0 ? name.slice(index + 1).toLowerCase() : '';
}

function uploadResponseUrl(response: Record<string, unknown>) {
  const data = recordFromUnknown(response.data);
  const file = recordFromUnknown(response.file);
  const result = recordFromUnknown(response.result);
  return firstString(
    response.url,
    response.fileUrl,
    response.file_url,
    response.objectUrl,
    response.object_url,
    response.downloadUrl,
    response.download_url,
    data?.url,
    data?.fileUrl,
    data?.file_url,
    file?.url,
    file?.fileUrl,
    file?.file_url,
    result?.url,
    result?.fileUrl,
    result?.file_url,
  );
}

function promptWithUploadSummary(prompt: string, uploads: PlaygroundUpload[]) {
  if (!uploads.length) return prompt;
  const lines = uploads.map((item) => `- ${uploadKindLabel(item.kind)} ${item.name}: ${item.url}`);
  return `${prompt}\n\n参考附件：\n${lines.join('\n')}`;
}

function mediaUploadRequestPayload(uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
  const images = uploads.filter((item) => item.kind === 'image').map((item) => item.url);
  const videos = uploads.filter((item) => item.kind === 'video').map((item) => item.url);
  const audios = uploads.filter((item) => item.kind === 'audio').map((item) => item.url);
  const payload: Record<string, string | string[]> = {};
  if (mode === 'image') {
    if (images.length) {
      payload.image = singleOrMany(images);
      payload.images = images;
    }
    return payload;
  }
  if (images.length) {
    payload.image = singleOrMany(images);
    payload.image_url = images[0];
    payload.images = images;
    payload.reference_image = singleOrMany(images);
  }
  if (videos.length) {
    payload.reference_video = singleOrMany(videos);
    payload.video_url = videos[0];
  }
  if (audios.length) {
    payload.reference_audio = singleOrMany(audios);
    payload.audio_url = audios[0];
  }
  return payload;
}

function singleOrMany(values: string[]) {
  return values.length === 1 ? values[0] : values;
}

function uploadKindLabel(kind: PlaygroundUploadKind) {
  if (kind === 'image') return '图片';
  if (kind === 'video') return '视频';
  if (kind === 'audio') return '音频';
  return '文件';
}

function formatFileSize(size: number) {
  if (!Number.isFinite(size) || size <= 0) return '';
  if (size < 1024) return `${size} B`;
  if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
  return `${(size / 1024 / 1024).toFixed(1)} MB`;
}

function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
  if (mode === 'chat') {
    return filterWithFallback(models, ['text_generate', 'chat', 'responses', 'text']);
  }
  if (mode === 'image') {
    const preferredTypes = hasReference ? ['image_edit', 'images.edits'] : ['image_generate', 'images.generations'];
    return filterWithFallback(models, [...preferredTypes, 'image']);
  }
  const videoTypesByMode: Record<VideoCreateMode, string[]> = {
    first_last_frame: ['image_to_video', 'video_first_last_frame', 'video_generate'],
    omni_reference: ['omni_video', 'video_reference', 'video_generate'],
    text_to_video: ['text_to_video', 'video_generate'],
  };
  return filterWithFallback(models, [...videoTypesByMode[videoMode], 'video']);
}

function filterWithFallback(models: PlatformModel[], modelTypes: string[]) {
  const exact = models.filter((model) => model.modelType.some((type) => modelTypes.includes(type)));
  return exact.length ? exact : models.filter((model) => modelTypes.some((type) => model.modelType.some((modelType) => modelType.includes(type) || type.includes(modelType))));
}

function buildModelOptions(models: PlatformModel[]): ModelOption[] {
  const grouped = new Map<string, ModelOption>();
  models.forEach((model) => {
    const value = model.modelAlias || model.modelName;
    if (!value) return;
    const current = grouped.get(value);
    if (current) {
      current.count += 1;
      current.models.push(model);
      if (!current.provider.includes(model.provider || '')) {
        current.provider = [current.provider, model.provider].filter(Boolean).join(' / ');
      }
      return;
    }
    grouped.set(value, {
      count: 1,
      label: model.modelAlias || model.displayName || model.modelName,
      models: [model],
      provider: model.provider || model.platformName || '',
      value,
    });
  });
  return Array.from(grouped.values()).sort((a, b) => a.label.localeCompare(b.label));
}

function modelOptionLabel(option: ModelOption) {
  const count = option.count > 1 ? ` · ${option.count} 个客户端` : '';
  const provider = option.provider ? ` · ${option.provider}` : '';
  return `${option.label}${provider}${count}`;
}

function resolveMediaRunModelValue(run: MediaGenerationRun, modelOptions: ModelOption[]) {
  const candidates = [
    run.modelValue,
    run.task?.requestedModel,
    taskRequestModel(run.task),
    run.task?.model,
    run.task?.resolvedModel,
  ];
  for (const candidate of candidates) {
    const value = resolveModelOptionValue(candidate, modelOptions);
    if (value) return value;
  }
  return '';
}

function resolveModelOptionValue(value: unknown, modelOptions: ModelOption[]) {
  const raw = stringFromUnknown(value);
  if (!raw) return '';
  const direct = modelOptions.find((item) => item.value === raw);
  if (direct) return direct.value;
  const matched = modelOptions.find((item) => item.models.some((model) => (
    model.modelAlias === raw
    || model.modelName === raw
    || model.displayName === raw
  )));
  return matched?.value ?? '';
}

function taskRequestModel(task: GatewayTask | undefined) {
  return stringFromUnknown(task?.request?.model);
}

function firstString(...values: unknown[]) {
  for (const value of values) {
    const text = stringFromUnknown(value);
    if (text) return text;
  }
  return '';
}

function updateMediaRun(runs: MediaGenerationRun[], localId: string, patch: Partial<MediaGenerationRun>) {
  return runs.map((run) => run.localId === localId ? { ...run, ...patch } : run);
}

function readStoredMediaRuns(): MediaGenerationRun[] {
  if (typeof window === 'undefined') return [];
  try {
    const raw = window.localStorage.getItem(MEDIA_RUNS_STORAGE_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw) as unknown;
    const record = recordFromUnknown(parsed);
    const source = Array.isArray(parsed) ? parsed : record?.runs;
    if (!Array.isArray(source)) return [];
    const runs = source
      .map((item, index) => mediaRunFromStorage(item, index))
      .filter((item): item is MediaGenerationRun => Boolean(item));
    return sortMediaRunsByCreatedAt(runs).slice(-MEDIA_RUNS_STORAGE_LIMIT);
  } catch {
    return [];
  }
}

function writeStoredMediaRuns(runs: MediaGenerationRun[]) {
  if (typeof window === 'undefined') return;
  try {
    if (!runs.length) {
      window.localStorage.removeItem(MEDIA_RUNS_STORAGE_KEY);
      return;
    }
    const storedRuns = sortMediaRunsByCreatedAt(runs).slice(-MEDIA_RUNS_STORAGE_LIMIT);
    window.localStorage.setItem(MEDIA_RUNS_STORAGE_KEY, JSON.stringify({
      runs: storedRuns,
      version: 1,
    }));
  } catch {
    // Best effort only: private mode or full storage should not break generation.
  }
}

function mediaRunFromStorage(value: unknown, index: number): MediaGenerationRun | undefined {
  const record = recordFromUnknown(value);
  if (!record) return undefined;
  const mode = record.mode === 'image' || record.mode === 'video' ? record.mode : undefined;
  const prompt = stringFromUnknown(record.prompt);
  if (!mode || !prompt) return undefined;
  const task = taskFromStorage(record.task);
  const createdAt = dateStringFromUnknown(record.createdAt) ?? new Date().toISOString();
  const localId = stringFromUnknown(record.localId) || task?.id || `stored-${index}-${createdAt}`;
  const modelValue = stringFromUnknown(record.modelValue) || task?.requestedModel || taskRequestModel(task) || task?.model || '';
  const modelLabel = stringFromUnknown(record.modelLabel) || modelValue || '未知模型';
  let status: MediaGenerationRun['status'] = stringFromUnknown(record.status) || task?.status || 'failed';
  let error = stringFromUnknown(record.error);
  if (status === 'submitting' && !task?.id) {
    status = 'failed';
    error = error || '任务提交已中断，请重新生成。';
  }
  return {
    createdAt,
    error,
    localId,
    mode,
    modelLabel,
    modelValue,
    prompt,
    settings: mediaSettingsFromStorage(record.settings),
    status,
    task,
  };
}

function sortMediaRunsByCreatedAt(runs: MediaGenerationRun[]) {
  return [...runs].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
}

function mediaSettingsFromStorage(value: unknown): MediaGenerationSettings {
  const fallback = defaultMediaGenerationSettings();
  const record = recordFromUnknown(value);
  if (!record) return fallback;
  return {
    aspectRatio: stringFromUnknown(record.aspectRatio) || fallback.aspectRatio,
    countPreset: countPresetFromStorage(record.countPreset, fallback.countPreset),
    customCount: numberFromUnknown(record.customCount, fallback.customCount, 1, 20),
    durationSeconds: numberFromUnknown(record.durationSeconds ?? record.duration_seconds ?? record.duration, fallback.durationSeconds, 1, 3600),
    height: numberFromUnknown(record.height, fallback.height, 128, 8192),
    outputMode: record.outputMode === 'group' ? 'group' : 'single',
    outputAudio: booleanFromUnknown(record.outputAudio ?? record.output_audio ?? record.audio, fallback.outputAudio),
    resolution: stringFromUnknown(record.resolution) || fallback.resolution,
    width: numberFromUnknown(record.width, fallback.width, 128, 8192),
  };
}

function countPresetFromStorage(value: unknown, fallback: MediaGenerationSettings['countPreset']) {
  if (value === 'custom') return value;
  const numeric = Number(value);
  if (numeric === 1 || numeric === 2 || numeric === 3 || numeric === 4) return numeric;
  return fallback;
}

function taskFromStorage(value: unknown): GatewayTask | undefined {
  const record = recordFromUnknown(value);
  if (!record || !stringFromUnknown(record.id) || !stringFromUnknown(record.status)) return undefined;
  return record as unknown as GatewayTask;
}

function recordFromUnknown(value: unknown): Record<string, unknown> | undefined {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
  return value as Record<string, unknown>;
}

function stringFromUnknown(value: unknown) {
  return typeof value === 'string' ? value.trim() : '';
}

function dateStringFromUnknown(value: unknown) {
  const raw = stringFromUnknown(value);
  if (!raw) return undefined;
  const time = Date.parse(raw);
  return Number.isFinite(time) ? raw : undefined;
}

function numberFromUnknown(value: unknown, fallback: number, min: number, max: number) {
  const number = typeof value === 'number' ? value : Number(value);
  if (!Number.isFinite(number)) return fallback;
  return Math.min(max, Math.max(min, Math.round(number)));
}

function booleanFromUnknown(value: unknown, fallback: boolean) {
  if (value === true || value === 'true') return true;
  if (value === false || value === 'false') return false;
  return fallback;
}

function newLocalId() {
  return typeof crypto !== 'undefined' && 'randomUUID' in crypto
    ? crypto.randomUUID()
    : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
