import {
  CloseOutlined,
  CopyOutlined,
  DeleteOutlined,
  DisconnectOutlined,
  DownloadOutlined,
  DownOutlined,
  ExclamationCircleOutlined,
  FullscreenExitOutlined,
  FullscreenOutlined,
  MessageOutlined,
  PaperClipOutlined,
  PlusOutlined,
  RedoOutlined,
  SendOutlined,
  SyncOutlined,
  ThunderboltOutlined,
  UpOutlined,
} from '@ant-design/icons';
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import {
  startTransition,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ChangeEvent,
  type ClipboardEvent,
  type ReactNode,
  type RefObject,
  type TouchEvent,
} from 'react';
import { InlineImage } from '../../../components/common/InlineImage';
import { CodexDiffBlock } from '../../../components/previewer';
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
import {
  ChatPreviewBody,
  resolveChatPreviewGlyph,
  resolveChatPreviewKindLabel,
  type ChatPreviewKind,
} from './ChatPreviewBody';
import { triggerResourceDownload } from './downloadUtils';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils';
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
import { openChatExternalLink } from './linkNavigation';
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
import { extractChatMessageParts } from './messageParts';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatMessagePart } from './types';

const KST_TIME_ZONE = 'Asia/Seoul';
const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
const KST_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('sv-SE', {
  timeZone: KST_TIME_ZONE,
  year: 'numeric',
  month: '2-digit',
  day: '2-digit',
  hour: '2-digit',
  minute: '2-digit',
  second: '2-digit',
  hour12: false,
});

type ChatTypeOption = {
  value: string;
  label: string;
  description: string;
  disabled?: boolean;
};

type PreviewOption = {
  id: string;
  label: string;
  url: string;
  kind: string;
};

type QueuedRequestOption = {
  requestId: string;
  order: number;
  text: string;
};

type InlinePreviewKind = ChatPreviewKind;

type InlinePreviewTarget = {
  url: string;
  label: string;
  kind: InlinePreviewKind;
};

type OpenPreviewTarget =
  | string
  | {
      id: string;
      label: string;
      url: string;
      kind: InlinePreviewKind;
      source?: 'message' | 'context';
    };

type PendingComposerUpload = {
  key: string;
  name: string;
  status: 'uploading' | 'uploaded' | 'failed';
  reason?: string;
};

type PreviewFetchError = Error & {
  status?: number;
};

const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;

type MessageRenderPayload = {
  previewSourceText: string;
  visibleText: string;
  diffBlocks: string[];
  rankedLinkTargets: RankedLinkPreviewTarget[];
  linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
};

const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
const TITLE_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:title|제목)\s*[:=-]\s*(.+)$/i;
const LINK_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:link|url|href|링크)\s*[:=-]\s*(https?:\/\/\S+|\/\S+)$/i;

function normalizeInlinePreviewUrl(value: string) {
  return normalizeChatResourceUrl(value);
}

function classifyInlinePreviewKind(url: string): InlinePreviewKind {
  const pathname = url.toLowerCase().split('?')[0] ?? '';

  if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) {
    return 'image';
  }

  if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) {
    return 'video';
  }

  if (/\.(md|markdown)$/i.test(pathname)) {
    return 'markdown';
  }

  if (/\.(diff|patch)$/i.test(pathname)) {
    return 'diff';
  }

  if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
    return 'code';
  }

  if (/\.(txt|log|csv)$/i.test(pathname)) {
    return 'document';
  }

  if (/\.pdf$/i.test(pathname)) {
    return 'pdf';
  }

  return 'file';
}

function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') {
  if (typeof document === 'undefined') {
    return;
  }

  const blob = new Blob([content], { type: mimeType });
  const objectUrl = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = objectUrl;
  link.download = fileName;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(objectUrl);
}

function buildInlinePreviewLabel(url: string) {
  try {
    const parsed = new URL(url);
    return parsed.pathname.split('/').filter(Boolean).at(-1) || parsed.hostname;
  } catch {
    return url;
  }
}

function buildPreviewFileName(item: Pick<PreviewOption, 'url' | 'label'>) {
  try {
    const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
    const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
    return fileName || item.label.trim() || item.url;
  } catch {
    return item.label.trim() || item.url;
  }
}

function resolvePreviewFileExtension(item: Pick<PreviewOption, 'url' | 'label'>) {
  const fileName = buildPreviewFileName(item).toLowerCase();
  const match = fileName.match(/\.([a-z0-9]{1,16})$/i);
  return match?.[1] ?? '';
}

function buildResourceChipMeta(item: Pick<PreviewOption, 'url' | 'label' | 'kind'>) {
  const extension = resolvePreviewFileExtension(item);

  if (extension) {
    return extension.toUpperCase();
  }

  return resolveChatPreviewKindLabel(item.kind as ChatPreviewKind)
    .replace(/\s+preview$/i, '')
    .replace(/\s+download$/i, '')
    .toUpperCase();
}

function normalizeRankedLinkTitle(value: string) {
  return value
    .replace(/^\[(.+)\]\([^)]+\)$/u, '$1')
    .replace(/\s+/g, ' ')
    .trim();
}

function extractRankedLinkTargets(text: string) {
  const lines = String(text ?? '').split('\n');
  const keptLines: string[] = [];
  const rankedLinkTargets: RankedLinkPreviewTarget[] = [];
  const seen = new Set<string>();

  const pushRankedLink = (title: string, url: string) => {
    const normalizedUrl = normalizeInlinePreviewUrl(url.trim());
    const normalizedTitle = normalizeRankedLinkTitle(title) || buildInlinePreviewLabel(normalizedUrl);
    const key = `${normalizedTitle}::${normalizedUrl}`;

    if (seen.has(key)) {
      return;
    }

    seen.add(key);
    rankedLinkTargets.push({
      title: normalizedTitle,
      url: normalizedUrl,
    });
  };

  for (let index = 0; index < lines.length; index += 1) {
    const line = lines[index] ?? '';
    const trimmedLine = line.trim();

    if (!trimmedLine) {
      keptLines.push(line);
      continue;
    }

    const markdownMatches = [...trimmedLine.matchAll(MARKDOWN_LINK_PATTERN)];
    if (markdownMatches.length > 0 && RANK_LINE_PATTERN.test(trimmedLine)) {
      markdownMatches.forEach((match) => {
        const [, label, href] = match;
        if (href?.trim()) {
          pushRankedLink(label?.trim() || href.trim(), href);
        }
      });
      continue;
    }

    const titleMatch = trimmedLine.match(TITLE_VALUE_PATTERN);
    if (!titleMatch) {
      keptLines.push(line);
      continue;
    }

    const collectedLines = [line];
    const title = titleMatch[1]?.trim() ?? '';
    let url = '';
    let hasRank = RANK_LINE_PATTERN.test(trimmedLine);
    let cursor = index + 1;

    while (cursor < lines.length) {
      const candidate = lines[cursor] ?? '';
      const trimmedCandidate = candidate.trim();

      if (!trimmedCandidate) {
        collectedLines.push(candidate);
        cursor += 1;
        continue;
      }

      if (trimmedCandidate.match(TITLE_VALUE_PATTERN) && cursor !== index + 1) {
        break;
      }

      const linkMatch = trimmedCandidate.match(LINK_VALUE_PATTERN);
      if (linkMatch) {
        url = linkMatch[1]?.trim() ?? url;
        collectedLines.push(candidate);
        hasRank ||= RANK_LINE_PATTERN.test(trimmedCandidate);
        cursor += 1;
        continue;
      }

      if (RANK_LINE_PATTERN.test(trimmedCandidate)) {
        hasRank = true;
        collectedLines.push(candidate);
        cursor += 1;
        continue;
      }

      break;
    }

    if (title && url && hasRank) {
      pushRankedLink(title, url);
      index = cursor - 1;
      continue;
    }

    keptLines.push(...collectedLines);
    index = cursor - 1;
  }

  return {
    strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
    rankedLinkTargets,
  };
}

function buildComposerFilePickKey(file: File) {
  return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
}

function isClipboardImageFile(file: File) {
  const normalizedType = String(file.type ?? '').trim().toLowerCase();

  if (normalizedType.startsWith('image/')) {
    return true;
  }

  const normalizedName = String(file.name ?? '').trim().toLowerCase();
  return /\.(png|jpe?g|gif|webp|bmp|heic|heif)$/i.test(normalizedName);
}

function isGeneratedClipboardImageName(file: File) {
  const normalizedName = String(file.name ?? '').trim().toLowerCase();

  if (!normalizedName) {
    return true;
  }

  return /^(image|clipboard|pasted image)([-\s]?\d+)?\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif)$/i.test(normalizedName);
}

function getClipboardImageMimeRank(file: File) {
  const normalizedType = String(file.type ?? '').trim().toLowerCase();

  switch (normalizedType) {
    case 'image/png':
      return 0;
    case 'image/jpeg':
      return 1;
    case 'image/webp':
      return 2;
    case 'image/gif':
      return 3;
    case 'image/bmp':
      return 4;
    case 'image/heic':
    case 'image/heif':
      return 5;
    case 'image/tiff':
    case 'image/tif':
      return 6;
    default:
      return 7;
  }
}

function resolvePreferredClipboardImageFiles(files: File[]) {
  if (files.length <= 1) {
    return files;
  }
  const sortedFiles = [...files]
    .sort((left, right) => {
      const rankDifference = getClipboardImageMimeRank(left) - getClipboardImageMimeRank(right);

      if (rankDifference !== 0) {
        return rankDifference;
      }

      return right.size - left.size;
    })
    .slice(0, 1);

  if (files.every(isGeneratedClipboardImageName)) {
    return sortedFiles;
  }

  return sortedFiles;
}

function resolveComposerPasteFiles(clipboardData: DataTransfer) {
  const clipboardItemFiles = Array.from(clipboardData.items ?? [])
    .filter((item) => item.kind === 'file')
    .map((item) => item.getAsFile())
    .filter((file): file is File => file instanceof File)
    .filter((file) => file.size > 0);
  const clipboardFiles = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0);
  const candidateFiles = clipboardItemFiles.length > 0 ? clipboardItemFiles : clipboardFiles;
  const imageFiles = candidateFiles.filter(isClipboardImageFile);
  const filesToUse = imageFiles.length > 0 ? resolvePreferredClipboardImageFiles(imageFiles) : candidateFiles;

  return Array.from(new Map(filesToUse.map((file) => [buildComposerFilePickKey(file), file])).values());
}

async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
  const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
  let responseMessage = '';

  try {
    if (contentType.includes('application/json')) {
      const payload = (await response.json()) as { message?: string };
      responseMessage = String(payload.message ?? '').trim();
    } else {
      responseMessage = (await response.text()).trim();
    }
  } catch {
    responseMessage = '';
  }

  const statusLabel =
    response.status === 403
      ? '이 문서는 현재 권한으로 열 수 없습니다.'
      : response.status === 404
        ? '이 문서를 찾을 수 없습니다.'
        : response.status === 401
          ? '이 문서를 열기 위한 인증이 필요합니다.'
          : `preview 요청이 실패했습니다. (${response.status})`;
  const detail = responseMessage && responseMessage !== response.statusText ? responseMessage : response.statusText.trim();
  const error = new Error(detail ? `${statusLabel} ${detail}` : statusLabel) as PreviewFetchError;
  error.status = response.status;
  return error;
}

function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
  const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
  const seen = new Set<string>();
  const targets: InlinePreviewTarget[] = [];

  for (const matchedUrl of matches) {
    const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl);
    const kind = classifyInlinePreviewKind(normalizedUrl);

    if (kind === 'file') {
      continue;
    }

    if (seen.has(normalizedUrl)) {
      continue;
    }

    seen.add(normalizedUrl);
    targets.push({
      url: normalizedUrl,
      label: buildInlinePreviewLabel(normalizedUrl),
      kind,
    });
  }

  return targets;
}

function renderMessageInlineParts(line: string): ReactNode[] {
  const renderedParts: ReactNode[] = [];
  let cursor = 0;

  for (const match of line.matchAll(MARKDOWN_LINK_PATTERN)) {
    const [fullMatch, label, rawHref] = match;
    const start = match.index ?? 0;

    if (start > cursor) {
      renderedParts.push(line.slice(cursor, start));
    }

    const href = normalizeInlinePreviewUrl(rawHref.trim());
    renderedParts.push(
      <a
        key={`${href}-${start}`}
        href={href}
        target="_blank"
        rel="noreferrer noopener"
        onClick={(event) => {
          openChatExternalLink(href, event);
        }}
      >
        {label.trim() || href}
      </a>,
    );
    cursor = start + fullMatch.length;
  }

  if (cursor < line.length) {
    renderedParts.push(line.slice(cursor));
  }

  return renderedParts.length > 0 ? renderedParts : [line];
}

function renderMessageBody(text: string) {
  const lines = text.split('\n');

  return lines.map((line, index) => {
    const imageMatch = line.match(MARKDOWN_IMAGE_LINE_PATTERN);

    if (imageMatch) {
      const [, alt, rawSrc] = imageMatch;
      const src = normalizeInlinePreviewUrl(rawSrc.trim());

      return (
        <div key={`img-${index}`} className="app-chat-message__block app-chat-message__block--image">
          <InlineImage
            src={src}
            alt={alt.trim() || 'chat image'}
            className="app-chat-message__inline-image markdown-preview__image"
            fallbackText="이미지 preview를 불러오지 못했습니다."
          />
        </div>
      );
    }

    if (!line.length) {
      return <div key={`space-${index}`} className="app-chat-message__block app-chat-message__block--spacer" aria-hidden="true" />;
    }

    return (
      <div key={`line-${index}`} className="app-chat-message__block">
        {renderMessageInlineParts(line)}
      </div>
    );
  });
}

function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload {
  const structuredParts = Array.isArray(message.parts) ? message.parts : [];
  const extractedMessageParts = extractChatMessageParts(message.text);
  const text = extractedMessageParts.strippedText;
  const linkCardTargets = [
    ...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
    ...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
  ].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
  const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
    .map((match) => match[1]?.trim())
    .filter((value): value is string => Boolean(value));

  const diffStrippedText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
  const { strippedText: previewSourceText, rankedLinkTargets } = extractRankedLinkTargets(diffStrippedText);
  const visibleText = stripHiddenPreviewTags(previewSourceText);

  return {
    previewSourceText,
    visibleText,
    diffBlocks,
    rankedLinkTargets,
    linkCardTargets,
  };
}

function summarizeQueuedText(text: string) {
  const normalized = text.replace(/\s+/g, ' ').trim();
  return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
}

function normalizeAttachmentName(value: string) {
  return String(value ?? '').trim().toLowerCase();
}

function isActivityLogMessage(message: ChatMessage) {
  return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
}

function getMissingRequestMessageText(message: ChatMessage) {
  if (!isMissingRequestMessage(message)) {
    return '';
  }

  return message.text.slice(`${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n`.length).trim();
}

function getExecutionFailureMessageText(message: ChatMessage) {
  if (!isExecutionFailureMessage(message)) {
    return '';
  }

  return message.text.slice(`${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n`.length).trim();
}

function extractActivityLines(message: ChatMessage) {
  return message.text
    .slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
    .split('\n\n')
    .map((line) => line.trim())
    .filter(Boolean);
}

function summarizeActivityLines(lines: string[]) {
  for (let index = lines.length - 1; index >= 0; index -= 1) {
    const summary = lines[index]
      .split('\n')
      .map((line) => line.trim())
      .find((line) => line.startsWith('# 이유:') || line.startsWith('# 진행:') || line.startsWith('# 상태:'));

    if (!summary) {
      continue;
    }

    return summary.replace(/^#\s*(이유|진행|상태):\s*/u, '').trim();
  }

  return lines.at(-1) ?? '';
}

function isLikelyCollapsibleMessage(text: string) {
  const normalizedText = String(text ?? '').trim();

  if (!normalizedText) {
    return false;
  }

  if (normalizedText.length > COLLAPSIBLE_MESSAGE_CHAR_COUNT) {
    return true;
  }

  const visualLines = normalizedText
    .split('\n')
    .map((line) => line.trim())
    .filter(Boolean);

  return visualLines.length > COLLAPSIBLE_MESSAGE_LINE_COUNT;
}

function formatChatTimestamp(timestamp: string) {
  const normalized = String(timestamp ?? '').trim();

  if (!normalized) {
    return '';
  }

  if (KST_TIMESTAMP_PATTERN.test(normalized)) {
    return normalized;
  }

  const parsed = new Date(normalized);

  if (Number.isNaN(parsed.getTime())) {
    return normalized;
  }

  return KST_DATE_TIME_FORMATTER.format(parsed).replace(',', '');
}

function formatRequestStatusLabel(request: ChatConversationRequest | undefined) {
  switch (request?.status) {
    case 'accepted':
      return '접수됨';
    case 'queued':
      return '대기중';
    case 'started':
      return request.hasResponse ? '응답작성중' : '처리중';
    case 'completed':
      return '완료';
    case 'failed':
      return '실패';
    case 'cancelled':
      return '취소됨';
    case 'removed':
      return '삭제됨';
    default:
      return null;
  }
}

function isTerminalRequestStatus(status: ChatConversationRequest['status'] | undefined) {
  return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'removed';
}

function getRequestDetailText(request: ChatConversationRequest | undefined) {
  if (!request) {
    return '';
  }

  const normalizedStatusMessage = String(request.statusMessage ?? '').trim();

  if (!normalizedStatusMessage) {
    return '';
  }

  if (request.status === 'failed') {
    return normalizedStatusMessage.startsWith('실패')
      ? normalizedStatusMessage
      : `실패 사유: ${normalizedStatusMessage}`;
  }

  if (request.status === 'cancelled') {
    return normalizedStatusMessage.startsWith('취소')
      ? normalizedStatusMessage
      : `취소 사유: ${normalizedStatusMessage}`;
  }

  if (request.status === 'removed') {
    return normalizedStatusMessage;
  }

  return '';
}

function InlineMessagePreview({
  target,
  isExpanded,
  hasModalPreview,
  onOpenModalPreview,
  onToggle,
}: {
  target: InlinePreviewTarget;
  isExpanded: boolean;
  hasModalPreview: boolean;
  onOpenModalPreview: () => void;
  onToggle: () => void;
}) {
  const [textPreview, setTextPreview] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [previewError, setPreviewError] = useState('');
  const [previewContentType, setPreviewContentType] = useState('');

  useEffect(() => {
    if (!isExpanded) {
      return;
    }

    if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf' || target.kind === 'file') {
      return;
    }

    const controller = new AbortController();
    setIsLoading(true);
    setPreviewError('');
    setPreviewContentType('');

    fetch(target.url, {
      cache: 'no-store',
      signal: controller.signal,
    })
      .then(async (response) => {
        if (!response.ok) {
          throw await createPreviewFetchError(response);
        }

        setPreviewContentType(response.headers.get('content-type') ?? '');
        const text = await response.text();
        setTextPreview(text.slice(0, 1600));
      })
      .catch((error: unknown) => {
        if (controller.signal.aborted) {
          return;
        }

        setTextPreview('');
        setPreviewContentType('');
        setPreviewError(error instanceof Error ? error.message : 'preview를 불러오지 못했습니다.');
      })
      .finally(() => {
        if (!controller.signal.aborted) {
          setIsLoading(false);
        }
      });

    return () => {
      controller.abort();
    };
  }, [isExpanded, target.kind, target.url]);

  const handleCopyPreview = () => {
    void copyPreviewContent({
      kind: target.kind,
      url: target.url,
      fallbackText: textPreview,
    })
      .then((result) => {
        if (result === 'image') {
          message.success('preview 이미지를 복사했습니다.');
          return;
        }

        if (result === 'url') {
          message.success('preview 이미지 URL을 복사했습니다.');
          return;
        }

        message.success('preview 내용을 복사했습니다.');
      })
      .catch((error: unknown) => {
        message.error(error instanceof Error ? error.message : 'preview 내용을 복사하지 못했습니다.');
      });
  };

  return (
    <section className={`app-chat-preview-card${isExpanded ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}`}>
      <div className="app-chat-preview-card__header">
        <div className="app-chat-preview-card__meta">
          <div className="app-chat-preview-card__titles">
            <span className="app-chat-preview-card__label">{target.label}</span>
            <span className="app-chat-preview-card__kind">{resolveChatPreviewKindLabel(target.kind)}</span>
          </div>
        </div>
        <div className="app-chat-preview-card__actions">
          <Button
            type="text"
            size="small"
            className="app-chat-preview-card__action"
            icon={<CopyOutlined />}
            aria-label="preview 내용 복사"
            onClick={handleCopyPreview}
          />
          <Button
            type="text"
            size="small"
            className="app-chat-preview-card__action"
            icon={hasModalPreview && isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
            aria-label={hasModalPreview && isExpanded ? 'preview 100% 닫기' : 'preview 100%'}
            onClick={hasModalPreview ? onOpenModalPreview : onToggle}
          />
          <Button
            type="text"
            size="small"
            className="app-chat-preview-card__action"
            icon={<DownloadOutlined />}
            aria-label="preview 다운로드"
            onClick={() => {
              void triggerResourceDownload(target.url, buildPreviewFileName(target)).catch((error: unknown) => {
                message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
              });
            }}
          />
          <Button
            type="link"
            size="small"
            className="app-chat-preview-card__toggle"
            icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
            aria-label={isExpanded ? 'preview 접기' : 'preview 펼치기'}
            onClick={onToggle}
          />
        </div>
      </div>

      {isExpanded ? (
        <div className="app-chat-preview-card__body">
          <ChatPreviewBody
            target={target}
            previewText={textPreview}
            isPreviewLoading={isLoading}
            previewError={previewError}
            previewContentType={previewContentType}
            maxMarkdownBlocks={12}
          />
        </div>
      ) : null}
    </section>
  );
}

function DiffMessagePreview({
  diffText,
  fileCount,
  isExpanded,
  isFullscreen,
  onToggle,
  onToggleFullscreen,
}: {
  diffText: string;
  fileCount: number;
  isExpanded: boolean;
  isFullscreen: boolean;
  onToggle: () => void;
  onToggleFullscreen: () => void;
}) {
  const handleCopyDiff = () => {
    void copyText(diffText)
      .then(() => {
        message.success('diff를 복사했습니다.');
      })
      .catch((error: unknown) => {
        message.error(error instanceof Error ? error.message : 'diff를 복사하지 못했습니다.');
      });
  };

  return (
    <section
      className={`app-chat-preview-card${isExpanded || isFullscreen ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}${
        isFullscreen ? ' app-chat-preview-card--fullscreen' : ''
      }`}
    >
      <div className="app-chat-preview-card__header">
        <div className="app-chat-preview-card__meta">
          <div className="app-chat-preview-card__titles">
            <span className="app-chat-preview-card__label">Codex Diff</span>
            <span className="app-chat-preview-card__kind">{`diff preview · 파일 ${fileCount}개`}</span>
          </div>
        </div>
        <div className="app-chat-preview-card__actions">
          <Button
            type="text"
            size="small"
            className="app-chat-preview-card__action"
            icon={<CopyOutlined />}
            aria-label="diff 복사"
            onClick={handleCopyDiff}
          />
          <Button
            type="text"
            size="small"
            className="app-chat-preview-card__action"
            icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
            aria-label={isFullscreen ? 'diff 최대화 해제' : 'diff 최대화'}
            onClick={onToggleFullscreen}
          />
          <Button
            type="text"
            size="small"
            className="app-chat-preview-card__action"
            icon={<DownloadOutlined />}
            aria-label="diff 다운로드"
            onClick={() => {
              downloadTextFile(diffText, 'codex-result.diff', 'text/x-diff;charset=utf-8');
            }}
          />
          <Button
            type="link"
            size="small"
            className="app-chat-preview-card__toggle"
            icon={isExpanded || isFullscreen ? <UpOutlined /> : <DownOutlined />}
            aria-label={isExpanded || isFullscreen ? 'diff 접기' : 'diff 펼치기'}
            onClick={onToggle}
          />
        </div>
      </div>
      {isExpanded || isFullscreen ? (
        <div className="app-chat-preview-card__body">
          <div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
            <CodexDiffBlock
              diffText={diffText}
              showToolbar={false}
              expandAll={isFullscreen}
              summary={`파일 ${fileCount}개 diff preview`}
            />
          </div>
        </div>
      ) : null}
    </section>
  );
}

type ChatConversationViewProps = {
  viewportRef: RefObject<HTMLDivElement | null>;
  composerRef: RefObject<TextAreaRef | null>;
  visibleMessages: ChatMessage[];
  activeSystemStatus: string | null;
  isSystemStatusPending: boolean;
  showScrollToBottom: boolean;
  copiedMessageId: number | null;
  draft: string;
  composerAttachments: ChatComposerAttachment[];
  requestStateMap: Map<string, ChatConversationRequest>;
  isConversationLoading: boolean;
  conversationLoadingLabel: string;
  hasOlderMessages: boolean;
  isLoadingOlderMessages: boolean;
  isPullToLoadArmed: boolean;
  pullToLoadDistance: number;
  selectedChatTypeId: string | null;
  queuedRequests: QueuedRequestOption[];
  chatTypeOptions: ChatTypeOption[];
  previewItems: PreviewOption[];
  isResourceStripOpen: boolean;
  isComposerDisabled: boolean;
  isMobileViewport: boolean;
  isChatTypeSelectionLocked: boolean;
  isComposerAttachmentUploading: boolean;
  isSendWithoutContextEnabled: boolean;
  onViewportScroll: () => void;
  onViewportTouchEnd: () => void;
  onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
  onViewportTouchStart: (event: TouchEvent<HTMLDivElement>) => void;
  onDraftChange: (value: string) => void;
  onPickComposerFiles: (files: File[]) => ComposerFilePickResult | Promise<ComposerFilePickResult>;
  onRemoveComposerAttachment: (attachmentId: string) => void;
  onSelectChatType: (value: string) => void;
  onSend: (draftText?: string) => void;
  onSendImmediate: (draftText?: string) => void;
  onToggleSendWithoutContext: () => void;
  onClearDraft: () => void;
  onScrollToBottom: () => void;
  onToggleResourceStrip: () => void;
  onOpenPreview: (preview: OpenPreviewTarget, options?: { fullscreen?: boolean }) => void;
  onCopyMessage: (message: ChatMessage) => void;
  onRetryMessage: (message: ChatMessage) => void;
  onCancelMessage: (message: ChatMessage) => void;
  onDeleteRequest: (message: ChatMessage) => void;
  onRemoveQueuedRequest: (requestId: string) => void;
};

export function ChatConversationView({
  viewportRef,
  composerRef,
  visibleMessages,
  activeSystemStatus,
  isSystemStatusPending,
  showScrollToBottom,
  copiedMessageId,
  draft,
  composerAttachments,
  requestStateMap,
  isConversationLoading,
  conversationLoadingLabel,
  hasOlderMessages,
  isLoadingOlderMessages,
  isPullToLoadArmed,
  pullToLoadDistance,
  selectedChatTypeId,
  queuedRequests,
  chatTypeOptions,
  previewItems,
  isResourceStripOpen,
  isComposerDisabled,
  isMobileViewport,
  isChatTypeSelectionLocked,
  isComposerAttachmentUploading,
  isSendWithoutContextEnabled,
  onViewportScroll,
  onViewportTouchEnd,
  onViewportTouchMove,
  onViewportTouchStart,
  onDraftChange,
  onPickComposerFiles,
  onRemoveComposerAttachment,
  onSelectChatType,
  onSend,
  onSendImmediate,
  onToggleSendWithoutContext,
  onClearDraft,
  onScrollToBottom,
  onToggleResourceStrip,
  onOpenPreview,
  onCopyMessage,
  onRetryMessage,
  onCancelMessage,
  onDeleteRequest,
  onRemoveQueuedRequest,
}: ChatConversationViewProps) {
  const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
  const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
  const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
  const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
  const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
  const [showBusyOverlay, setShowBusyOverlay] = useState(false);
  const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
  const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
  const [composerDraft, setComposerDraft] = useState(draft);
  const fileInputRef = useRef<HTMLInputElement | null>(null);
  const activitySectionRefs = useRef(new Map<string, HTMLElement>());
  const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
  const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
  const lastReportedDraftRef = useRef(draft);

  useEffect(() => {
    if (draft === lastReportedDraftRef.current) {
      return;
    }

    setComposerDraft(draft);
  }, [draft]);

  useEffect(() => {
    if (composerDraft === lastReportedDraftRef.current) {
      return;
    }

    const timeoutId = window.setTimeout(() => {
      lastReportedDraftRef.current = composerDraft;
      startTransition(() => {
        onDraftChange(composerDraft);
      });
    }, 120);

    return () => {
      window.clearTimeout(timeoutId);
    };
  }, [composerDraft, onDraftChange]);

  const orderedMessages = useMemo(() => {
    const shouldDisplayActivityMessage = (activityMessage: ChatMessage) => {
      const requestId = activityMessage.clientRequestId?.trim();

      if (!requestId) {
        return true;
      }

      const requestState = requestStateMap.get(requestId);
      const hasCodexResponse = visibleMessages.some(
        (candidate) =>
          candidate.clientRequestId?.trim() === requestId &&
          candidate.author === 'codex' &&
          candidate.text.trim().length > 0 &&
          !isPreparingChatReplyText(candidate.text),
      );

      return !isTerminalRequestStatus(requestState?.status) || !hasCodexResponse;
    };

    const latestActivityByRequestId = new Map<string, ChatMessage>();
    const orphanActivityMessages: ChatMessage[] = [];
    const baseMessages = visibleMessages.filter((message) => {
      if (!isActivityLogMessage(message)) {
        return true;
      }

      const activityKey = message.clientRequestId?.trim();

      if (!activityKey) {
        orphanActivityMessages.push(message);
        return false;
      }

      if (shouldDisplayActivityMessage(message)) {
        latestActivityByRequestId.set(activityKey, message);
      }
      return false;
    });
    const insertedActivityRequestIds = new Set<string>();
    const ordered: ChatMessage[] = [];

    baseMessages.forEach((message) => {
      ordered.push(message);

      if (message.author !== 'user') {
        return;
      }

      const requestId = message.clientRequestId?.trim();

      if (!requestId) {
        return;
      }

      const activityMessage = latestActivityByRequestId.get(requestId);

      if (!activityMessage) {
        return;
      }

      ordered.push(activityMessage);
      insertedActivityRequestIds.add(requestId);
    });

    latestActivityByRequestId.forEach((message, requestId) => {
      if (!insertedActivityRequestIds.has(requestId)) {
        orphanActivityMessages.push(message);
      }
    });

    return [...ordered, ...orphanActivityMessages];
  }, [requestStateMap, visibleMessages]);
  const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
  const isChatTypeReadonly = isChatTypeSelectionLocked;
  const visiblePreviewItems = useMemo(() => {
    if (!showLatestResourceOnly) {
      return previewItems;
    }

    const seenFileNames = new Set<string>();
    return previewItems.filter((item) => {
      const fileName = buildPreviewFileName(item);

      if (seenFileNames.has(fileName)) {
        return false;
      }

      seenFileNames.add(fileName);
      return true;
    });
  }, [previewItems, showLatestResourceOnly]);

  useEffect(() => {
    if (typeof document === 'undefined') {
      return;
    }

    const { body, documentElement } = document;
    const previousBodyOverflow = body.style.overflow;
    const previousHtmlOverflow = documentElement.style.overflow;

    if (fullscreenPreviewKey) {
      body.style.overflow = 'hidden';
      documentElement.style.overflow = 'hidden';
    }

    return () => {
      body.style.overflow = previousBodyOverflow;
      documentElement.style.overflow = previousHtmlOverflow;
    };
  }, [fullscreenPreviewKey]);

  const setActivitySectionRef = (requestId: string, element: HTMLElement | null) => {
    if (element) {
      activitySectionRefs.current.set(requestId, element);
      return;
    }

    activitySectionRefs.current.delete(requestId);
  };

  const setMessageBodyRef = (messageId: number, element: HTMLDivElement | null) => {
    if (element) {
      messageBodyRefs.current.set(messageId, element);
      return;
    }

    messageBodyRefs.current.delete(messageId);
  };

  const scrollActivitySectionIntoView = (requestId: string) => {
    const section = activitySectionRefs.current.get(requestId);
    if (!section || typeof window === 'undefined') {
      return;
    }

    window.requestAnimationFrame(() => {
      section.scrollIntoView({
        block: 'nearest',
        behavior: 'smooth',
      });
    });
  };

  useEffect(() => {
    const requestIdsToCollapse = new Set<string>();

    orderedMessages.forEach((message) => {
      if (!isActivityLogMessage(message)) {
        return;
      }

      const requestId = message.clientRequestId?.trim();

      if (!requestId || autoCollapsedActivityRequestIdsRef.current.has(requestId)) {
        return;
      }

      const requestState = requestStateMap.get(requestId);
      const hasCodexResponse = orderedMessages.some(
        (candidate) => candidate.clientRequestId === requestId && candidate.author === 'codex' && candidate.text.trim().length > 0,
      );

      if (isTerminalRequestStatus(requestState?.status) || hasCodexResponse) {
        requestIdsToCollapse.add(requestId);
      }
    });

    if (requestIdsToCollapse.size === 0) {
      return;
    }

    requestIdsToCollapse.forEach((requestId) => {
      autoCollapsedActivityRequestIdsRef.current.add(requestId);
    });

    setCollapsedActivityRequestIds((current) => {
      const next = new Set(current);
      requestIdsToCollapse.forEach((requestId) => {
        next.add(requestId);
      });
      return Array.from(next);
    });
  }, [orderedMessages, requestStateMap]);

  useEffect(() => {
    if (typeof window === 'undefined') {
      return;
    }

    let frameId = 0;
    let resizeObserver: ResizeObserver | null = null;

    const measureCollapsibleMessages = () => {
      const nextIds: number[] = [];

      orderedMessages.forEach((message) => {
        if (message.author !== 'user' && message.author !== 'codex') {
          return;
        }

        if (isLikelyCollapsibleMessage(message.text)) {
          nextIds.push(message.id);
          return;
        }

        const element = messageBodyRefs.current.get(message.id);

        if (!element) {
          return;
        }

        const computedStyle = window.getComputedStyle(element);
        const rawLineHeight = Number.parseFloat(computedStyle.lineHeight);
        const lineHeight = Number.isFinite(rawLineHeight) ? rawLineHeight : 12 * 1.45;
        const collapsedMaxHeight = lineHeight * COLLAPSIBLE_MESSAGE_LINE_COUNT;
        const fullHeight = element.scrollHeight;

        if (fullHeight > collapsedMaxHeight + 4) {
          nextIds.push(message.id);
        }
      });

      setCollapsibleMessageIds((current) => {
        if (current.length === nextIds.length && current.every((id, index) => id === nextIds[index])) {
          return current;
        }

        return nextIds;
      });
    };

    const scheduleMeasure = () => {
      window.cancelAnimationFrame(frameId);
      frameId = window.requestAnimationFrame(measureCollapsibleMessages);
    };

    scheduleMeasure();
    window.addEventListener('resize', scheduleMeasure);

    if (typeof ResizeObserver !== 'undefined') {
      resizeObserver = new ResizeObserver(() => {
        scheduleMeasure();
      });

      orderedMessages.forEach((message) => {
        const element = messageBodyRefs.current.get(message.id);

        if (element) {
          resizeObserver?.observe(element);
        }
      });
    }

    return () => {
      window.cancelAnimationFrame(frameId);
      window.removeEventListener('resize', scheduleMeasure);
      resizeObserver?.disconnect();
    };
  }, [orderedMessages, expandedMessageIds]);

  useEffect(() => {
    if (isConversationLoading) {
      setShowBusyOverlay(false);
      return;
    }

    if (!isComposerAttachmentUploading) {
      setShowBusyOverlay(false);
      return;
    }

    const timeoutId = window.setTimeout(() => {
      setShowBusyOverlay(true);
    }, 350);

    return () => {
      window.clearTimeout(timeoutId);
    };
  }, [isComposerAttachmentUploading, isConversationLoading]);

  useEffect(() => {
    if (pendingComposerUploads.length === 0) {
      return;
    }

    const uploadedAttachmentNames = new Set(
      composerAttachments.map((attachment) => normalizeAttachmentName(attachment.name)).filter(Boolean),
    );
    const resolvedUploads = pendingComposerUploads.filter((item) => {
      const normalizedName = normalizeAttachmentName(item.name);

      if (!normalizedName || !uploadedAttachmentNames.has(normalizedName)) {
        return false;
      }

      return item.status === 'uploaded' || item.status === 'failed';
    });

    if (resolvedUploads.length > 0) {
      const resolvedKeys = new Set(resolvedUploads.map((item) => item.key));
      setPendingComposerUploads((current) => current.filter((item) => !resolvedKeys.has(item.key)));
    }
  }, [composerAttachments, pendingComposerUploads]);

  const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';

  const syncPendingComposerUploads = async (files: File[]) => {
    const nextPendingNames = new Set(files.map((file) => normalizeAttachmentName(file.name)).filter(Boolean));
    const nextPendingUploads = files.map((file) => ({
      key: buildComposerFilePickKey(file),
      name: file.name,
      status: 'uploading' as const,
    }));
    const pendingKeys = new Set(nextPendingUploads.map((item) => item.key));

    setPendingComposerUploads((current) => [
      ...current.filter((item) => !pendingKeys.has(item.key) && !nextPendingNames.has(normalizeAttachmentName(item.name))),
      ...nextPendingUploads,
    ]);

    let result: ComposerFilePickResult = { items: [] };

    try {
      result = (await onPickComposerFiles(files)) ?? { items: [] };
    } catch {
      result = {
        items: nextPendingUploads.map((item) => ({
          key: item.key,
          fileName: item.name,
          status: 'failed',
        })),
      };
    }

    const resultByKey = new Map<string, ComposerFilePickResult['items'][number]>(
      result.items.map((item) => [item.key, item]),
    );

    setPendingComposerUploads((current) =>
      current.flatMap((item) => {
        if (!pendingKeys.has(item.key)) {
          return [item];
        }

        const matched = resultByKey.get(item.key);

        if (!matched || matched.status === 'failed') {
          return [{ ...item, status: 'failed', reason: matched?.reason }];
        }

        return [{ ...item, status: 'uploaded', reason: undefined }];
      }),
    );
  };

  const handleComposerFileChange = (event: ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(event.target.files ?? []);
    event.target.value = '';

    if (files.length === 0) {
      return;
    }

    void syncPendingComposerUploads(files);
  };

  const handleComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
    const clipboardData = event.clipboardData;

    if (!clipboardData) {
      return;
    }
    const files = resolveComposerPasteFiles(clipboardData);

    if (files.length === 0) {
      return;
    }

    event.preventDefault();
    void syncPendingComposerUploads(files);
  };

  const dismissPendingComposerUpload = (key: string) => {
    setPendingComposerUploads((current) => current.filter((item) => item.key !== key));
  };

  const composerAttachmentStrip =
    pendingComposerUploads.length > 0 || composerAttachments.length > 0 ? (
      <div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
        {pendingComposerUploads.map((upload) => (
          <div
            key={`pending:${upload.key}`}
            className={`app-chat-panel__composer-attachment-chip app-chat-panel__composer-attachment-chip--pending${
              upload.status === 'failed' ? ' app-chat-panel__composer-attachment-chip--failed' : ''
            }`}
            title={upload.status === 'failed' ? upload.reason ?? '업로드 실패' : undefined}
          >
            <span className="app-chat-panel__composer-attachment-name">{upload.name}</span>
            <span className="app-chat-panel__composer-attachment-pending-label">
              {upload.status === 'failed'
                ? upload.reason ?? '업로드 실패'
                : upload.status === 'uploaded'
                  ? '첨부 반영 중'
                  : '업로드 중'}
            </span>
            {upload.status === 'failed' ? (
              <Button
                type="text"
                size="small"
                className="app-chat-panel__composer-attachment-remove"
                icon={<CloseOutlined />}
                aria-label={`${upload.name} 업로드 실패 항목 닫기`}
                onClick={() => {
                  dismissPendingComposerUpload(upload.key);
                }}
              />
            ) : null}
          </div>
        ))}
        {composerAttachments.map((attachment) => (
          <div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
            <span className="app-chat-panel__composer-attachment-name">{attachment.name}</span>
            <Button
              type="text"
              size="small"
              className="app-chat-panel__composer-attachment-remove"
              icon={<CloseOutlined />}
              aria-label={`${attachment.name} 첨부 제거`}
              onClick={() => {
                onRemoveComposerAttachment(attachment.id);
              }}
            />
          </div>
        ))}
      </div>
    ) : null;
  const composerPlaceholder = isComposerDisabled
    ? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
    : isMobileViewport
      ? '메시지를 입력하세요.'
      : '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';

  const renderActivityCard = (message: ChatMessage) => {
    const requestId = message.clientRequestId?.trim() || String(message.id);
    const isExpanded = !collapsedActivityRequestIds.includes(requestId);
    const lines = extractActivityLines(message);
    const liveStatusLine = summarizeActivityLines(lines) || '활동 로그를 불러오는 중입니다.';
    const activityCountLabel = `${lines.length}개 로그`;

    return (
      <div key={`activity-${message.id}`} className="app-chat-message-stack app-chat-message-stack--system">
        <section
          ref={(element) => {
            setActivitySectionRef(requestId, element);
          }}
          className={`app-chat-preview-card app-chat-preview-card--activity${
            isExpanded ? ' app-chat-preview-card--activity-expanded' : ' app-chat-preview-card--activity-collapsed'
          }`}
        >
          <div className="app-chat-preview-card__header">
            <div className="app-chat-preview-card__meta">
              <span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--activity" aria-hidden="true">
                <MessageOutlined />
              </span>
              <div className="app-chat-preview-card__titles">
                <div className="app-chat-activity-card__title-row">
                  <span className="app-chat-preview-card__label">활동 로그</span>
                  <span className="app-chat-activity-card__badge">{activityCountLabel}</span>
                </div>
                <span className="app-chat-preview-card__kind app-chat-activity-card__request-id">{requestId}</span>
              </div>
            </div>
            <Button
              type="link"
              size="small"
              className="app-chat-preview-card__toggle"
              icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
              aria-label={isExpanded ? '활동 로그 접기' : '활동 로그 펼치기'}
              iconPosition="end"
              onClick={() => {
                setCollapsedActivityRequestIds((current) => {
                  if (current.includes(requestId)) {
                    scrollActivitySectionIntoView(requestId);
                    return current.filter((currentRequestId) => currentRequestId !== requestId);
                  }

                  return [...current, requestId];
                });
              }}
            >
              {isExpanded ? '접기' : '펼치기'}
            </Button>
          </div>
          <div className="app-chat-preview-card__body app-chat-preview-card__body--activity app-chat-preview-card__body--activity-summary">
            <div className="app-chat-activity-card__summary" aria-label="실시간 상태">
              <span className="app-chat-activity-card__summary-label">현재 상태</span>
              <span className="app-chat-message__activity-status">{liveStatusLine}</span>
            </div>
          </div>
          {isExpanded ? (
            <div className="app-chat-preview-card__body app-chat-preview-card__body--activity">
              <div className="app-chat-activity-log" role="log" aria-live="polite" aria-label="활동 로그 상세">
                {lines.map((line, lineIndex) => (
                  <div key={`${requestId}-${lineIndex}`} className="app-chat-activity-log__item">
                    <span className="app-chat-activity-log__index" aria-hidden="true">
                      {lineIndex + 1}
                    </span>
                    <p className="app-chat-activity-log__line">{line}</p>
                  </div>
                ))}
              </div>
            </div>
          ) : null}
        </section>
      </div>
    );
  };

  return (
    <div className="app-chat-panel__conversation-view">
      {isConversationLoading ? (
        <div className="app-chat-panel__conversation-loading" aria-live="polite">
          <Spin size="large" />
          <strong>{conversationLoadingLabel}</strong>
          <span>재접속 중에는 채팅방 내용을 다시 맞춘 뒤 자연스럽게 표시합니다.</span>
        </div>
      ) : null}

      {showBusyOverlay ? (
        <div className="app-chat-panel__busy-overlay" aria-live="polite" aria-busy="true">
          <Spin size="large" />
          <strong>{busyOverlayLabel}</strong>
          <span>처리가 끝나면 화면이 바로 갱신됩니다.</span>
        </div>
      ) : null}

      <div
        className={`app-chat-panel__conversation-view-inner${isConversationLoading ? ' is-loading' : ''}${
          showBusyOverlay ? ' is-busy' : ''
        }`}
      >
        <div className="app-chat-panel__conversation-toolbar">
          <Button
            type={isResourceStripOpen ? 'default' : 'text'}
            size="small"
            className="app-chat-panel__conversation-toggle"
            icon={<PaperClipOutlined />}
            aria-label={isResourceStripOpen ? '채팅 리소스 숨기기' : '채팅 리소스 보기'}
            onClick={onToggleResourceStrip}
          />
        </div>

        {isResourceStripOpen ? (
          <div className="app-chat-panel__resource-strip">
            {previewItems.length > 0 ? (
              <>
                <label className="app-chat-panel__resource-strip-filter">
                  <Checkbox
                    checked={showLatestResourceOnly}
                    onChange={(event) => {
                      setShowLatestResourceOnly(event.target.checked);
                    }}
                  >
                    같은 파일명은 최종 리소스만 보기
                  </Checkbox>
                </label>
                <div className="app-chat-panel__resource-strip-list">
                  {visiblePreviewItems.map((item) => (
                    <button
                      key={item.id}
                      type="button"
                      className="app-chat-panel__resource-chip"
                      onClick={() => {
                        onOpenPreview(item.id);
                      }}
                    >
                      <span className="app-chat-panel__resource-chip-main">
                        <span className="app-chat-panel__resource-chip-icon" aria-hidden="true">
                          {resolveChatPreviewGlyph(item.kind as ChatPreviewKind)}
                        </span>
                        <span className="app-chat-panel__resource-chip-label" title={item.label}>
                          {item.label}
                        </span>
                      </span>
                      <span
                        className="app-chat-panel__resource-chip-meta"
                        aria-label={`${resolveChatPreviewKindLabel(item.kind as ChatPreviewKind)} 형식`}
                      >
                        {buildResourceChipMeta(item)}
                      </span>
                    </button>
                  ))}
                </div>
              </>
            ) : (
              <span className="app-chat-panel__resource-strip-empty">
                현재 대화에 바로 열 수 있는 리소스가 없습니다.
              </span>
            )}
          </div>
        ) : null}

        <div
          ref={viewportRef}
          className="app-chat-panel__messages"
          onScroll={onViewportScroll}
          onTouchEnd={onViewportTouchEnd}
          onTouchMove={onViewportTouchMove}
          onTouchStart={onViewportTouchStart}
        >
        {hasOlderMessages || isLoadingOlderMessages || pullToLoadDistance > 0 ? (
          <div
            className={`app-chat-panel__history-loader${
              isLoadingOlderMessages ? ' is-loading' : ''
            }${isPullToLoadArmed ? ' is-armed' : ''}`}
            style={{
              maxHeight: `${Math.max(isLoadingOlderMessages ? 52 : 0, pullToLoadDistance)}px`,
              opacity: isLoadingOlderMessages || pullToLoadDistance > 0 ? 1 : 0.72,
            }}
          >
            <Spin size="small" spinning={isLoadingOlderMessages} />
            <span>
              {isLoadingOlderMessages
                ? '이전 대화를 동기화하는 중입니다.'
                : isPullToLoadArmed
                  ? '손을 놓으면 이전 대화를 더 불러옵니다.'
                  : hasOlderMessages
                    ? '최상단에서 아래로 끌어당겨 이전 대화를 불러오세요.'
                    : '이전 대화가 없습니다.'}
            </span>
          </div>
        ) : null}
        {orderedMessages.map((message) => {
          const canCollapseMessage = collapsibleMessageIds.includes(message.id);
          const isExpandedMessage = expandedMessageIds.includes(message.id);
          const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
          const isRecoveredMissingRequest = isMissingRequestMessage(message);
          const isRecoveredExecutionFailure = isExecutionFailureMessage(message);
          const baseMessageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}${
            isRecoveredMissingRequest || isRecoveredExecutionFailure ? ' app-chat-message__body--system-status' : ''
          }`;
          const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets } = extractMessageRenderPayload(message);
          const renderedText = isRecoveredMissingRequest
            ? getMissingRequestMessageText(message)
            : isRecoveredExecutionFailure
              ? getExecutionFailureMessageText(message)
              : visibleText;

          if (isActivityLogMessage(message)) {
            return renderActivityCard(message);
          }

          const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
          const hasPreviewCards =
            diffBlocks.length > 0 || inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0;
          const shouldRenderStandalonePreview =
            hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
          const stackClassName = [
              `app-chat-message-stack app-chat-message-stack--${message.author}`,
              shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
            ]
            .filter(Boolean)
            .join(' ');
          const requestState = message.clientRequestId ? requestStateMap.get(message.clientRequestId) : undefined;
          const requestStatusLabel = formatRequestStatusLabel(requestState);
          const requestDetailText = getRequestDetailText(requestState);

          return (
            <div key={message.id} className={stackClassName}>
              {shouldRenderStandalonePreview ? null : (
                <article
                  className={`app-chat-message ${
                    isRecoveredMissingRequest || isRecoveredExecutionFailure
                      ? 'app-chat-message--system-inline'
                      : `app-chat-message--${message.author}`
                  }`}
                >
                  <div className="app-chat-message__header">
                    <div className="app-chat-message__header-meta">
                      <strong>
                        {isRecoveredMissingRequest
                          ? '원문 누락'
                          : isRecoveredExecutionFailure
                            ? '실행 실패'
                          : message.author === 'codex'
                            ? 'Codex'
                            : message.author === 'user'
                              ? 'You'
                              : 'System'}
                      </strong>
                      <span>{formatChatTimestamp(message.timestamp)}</span>
                      {message.author === 'user' && requestStatusLabel ? (
                        <span className="app-chat-message__status" aria-label={`요청 상태 ${requestStatusLabel}`}>
                          <span>{requestStatusLabel}</span>
                        </span>
                      ) : null}
                      {message.author === 'user' && message.deliveryStatus === 'retrying' ? (
                        <span className="app-chat-message__status app-chat-message__status--retrying" aria-label="재전송 중">
                          <SyncOutlined spin />
                          <span>{message.retryCount && message.retryCount > 0 ? `재시도 ${message.retryCount}` : '재전송 대기'}</span>
                        </span>
                      ) : null}
                      {message.author === 'user' && message.deliveryStatus === 'failed' ? (
                        <span className="app-chat-message__status app-chat-message__status--failed" aria-label="전송 실패">
                          <ExclamationCircleOutlined />
                          <span>전송 실패</span>
                        </span>
                      ) : null}
                      {message.author === 'user' &&
                      (message.deliveryStatus === 'retrying' || message.deliveryStatus === 'failed') ? (
                        <Button
                          type="link"
                          size="small"
                          danger
                          className="app-chat-message__cancel"
                          icon={<CloseOutlined />}
                          onClick={() => {
                            onCancelMessage(message);
                          }}
                        >
                          취소
                        </Button>
                      ) : null}
                      {message.author === 'user' && message.deliveryStatus === 'failed' ? (
                        <>
                          <Button
                            type="link"
                            size="small"
                            className="app-chat-message__retry"
                            icon={<RedoOutlined />}
                            onClick={() => {
                              onRetryMessage(message);
                            }}
                          >
                            재전송
                          </Button>
                        </>
                      ) : null}
                      {message.author === 'user' &&
                      requestState?.canDelete &&
                      requestState.status !== 'accepted' ? (
                        <Button
                          type="link"
                          size="small"
                          className="app-chat-message__retry app-chat-message__delete"
                          icon={<DeleteOutlined />}
                          aria-label="메시지 삭제"
                          onClick={() => {
                            onDeleteRequest(message);
                          }}
                        />
                      ) : null}
                    </div>
                    {message.author !== 'system' ? (
                      <Button
                        type="text"
                        size="small"
                        className="app-chat-message__header-action"
                        icon={<CopyOutlined />}
                        aria-label={copiedMessageId === message.id ? '복사됨' : message.author === 'user' ? '내 메시지 복사' : '답변 복사'}
                        onClick={() => {
                          onCopyMessage(message);
                        }}
                      />
                    ) : null}
                  </div>
                    <div
                      ref={(element) => {
                        setMessageBodyRef(message.id, element);
                      }}
                      className={baseMessageBodyClassName}
                    >
                    {renderedText ? renderMessageBody(renderedText) : null}
                  </div>
                  {message.author === 'user' && requestDetailText ? (
                    <div className="app-chat-message__request-detail" role="status" aria-live="polite">
                      {requestDetailText}
                    </div>
                  ) : null}
                  {canCollapseMessage ? (
                    <Button
                      type="text"
                      size="small"
                      className="app-chat-message__expand"
                      icon={isExpandedMessage ? <UpOutlined /> : <DownOutlined />}
                      aria-label={isExpandedMessage ? '메시지 접기' : '메시지 펼치기'}
                      onClick={() => {
                        setExpandedMessageIds((current) =>
                          current.includes(message.id) ? current.filter((id) => id !== message.id) : [...current, message.id],
                        );
                      }}
                    >
                      {isExpandedMessage ? '접기' : '펼치기'}
                    </Button>
                  ) : null}
                </article>
              )}
              {hasPreviewCards ? (
                <div className="app-chat-message-stack__previews">
                  {linkCardTargets.map((target) => (
                    <ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
                  ))}
                  {rankedLinkTargets.map((target) => (
                    <ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
                  ))}
                  {diffBlocks.map((diffText, index) => {
                    const previewKey = `${message.id}-diff-${index}`;

                      return (
                        <DiffMessagePreview
                          key={previewKey}
                          diffText={diffText}
                          fileCount={Math.max(1, Array.from(diffText.matchAll(/^diff --git /gm)).length)}
                          isExpanded={expandedPreviewKey === previewKey}
                          isFullscreen={fullscreenPreviewKey === previewKey}
                          onToggle={() => {
                            setExpandedPreviewKey((current) => {
                              if (fullscreenPreviewKey === previewKey) {
                                setFullscreenPreviewKey(null);
                                return null;
                              }

                              return current === previewKey ? null : previewKey;
                            });
                          }}
                          onToggleFullscreen={() => {
                            setFullscreenPreviewKey((current) => {
                              const nextKey = current === previewKey ? null : previewKey;

                              if (nextKey) {
                                setExpandedPreviewKey(previewKey);
                              }

                              return nextKey;
                            });
                          }}
                        />
                      );
                  })}
                  {inlinePreviewTargets.map((target) => {
                    const previewKey = `${message.id}-${target.url}`;
                    const matchedPreview = previewItemsByUrl.get(target.url);

                    return (
                      <InlineMessagePreview
                        key={previewKey}
                        target={target}
                        isExpanded={expandedPreviewKey === previewKey}
                        hasModalPreview
                        onOpenModalPreview={() => {
                          onOpenPreview(
                            matchedPreview
                              ? matchedPreview.id
                              : {
                                  id: previewKey,
                                  label: target.label,
                                  url: target.url,
                                  kind: target.kind,
                                  source: 'message',
                                },
                            { fullscreen: true },
                          );
                        }}
                        onToggle={() => {
                          setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
                        }}
                      />
                    );
                  })}
                </div>
              ) : null}
            </div>
          );
        })}
      </div>

      {activeSystemStatus ? (
        <div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
          <div
            className={`app-chat-panel__system-status${
              isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''
            }`}
          >
            <span>{activeSystemStatus}</span>
            {isSystemStatusPending ? (
              <div className="app-chat-panel__system-status-dots" aria-label="처리 중">
                <span className="app-chat-panel__system-status-dot" />
                <span className="app-chat-panel__system-status-dot" />
                <span className="app-chat-panel__system-status-dot" />
              </div>
            ) : null}
          </div>
        </div>
      ) : null}

      {showScrollToBottom ? (
        <div className="app-chat-panel__scroll-jump">
          <Button type="primary" shape="circle" icon={<DownOutlined />} aria-label="최하단으로 이동" onClick={onScrollToBottom} />
        </div>
      ) : null}

      <div className="app-chat-panel__composer">
        <div className="app-chat-panel__composer-topline">
          <div className="app-chat-panel__composer-utility-buttons">
            <Button
              icon={<PlusOutlined />}
              aria-label="파일 첨부"
              onClick={() => {
                fileInputRef.current?.click();
              }}
              disabled={isComposerDisabled}
              loading={isComposerAttachmentUploading}
            />
          </div>
          <div className="app-chat-panel__composer-type">
            <Select
              value={selectedChatTypeId ?? undefined}
              placeholder="컨텍스트를 선택하세요."
              options={chatTypeOptions.map((option) => ({
                value: option.value,
                disabled: option.disabled,
                label: (
                  <div className="app-chat-panel__type-option">
                    <span>{option.label}</span>
                  </div>
                ),
              }))}
              getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
              disabled={chatTypeOptions.length === 0 || isChatTypeReadonly}
              onChange={onSelectChatType}
            />
          </div>
          <div className="app-chat-panel__composer-actions">
            <div className="app-chat-panel__composer-action-buttons">
              <Button
                type={isSendWithoutContextEnabled ? 'primary' : 'default'}
                className={`app-chat-panel__composer-contextless-toggle${
                  isSendWithoutContextEnabled ? ' app-chat-panel__composer-contextless-toggle--active' : ''
                }`}
                icon={<DisconnectOutlined />}
                aria-label={
                  isSendWithoutContextEnabled
                    ? '다음 1회만 문맥 없이 보냄'
                    : '다음 전송을 문맥 없이 보내기'
                }
                title={
                  isSendWithoutContextEnabled
                    ? '다음 1회만 문맥 없이 보냄'
                    : '다음 전송을 문맥 없이 보내기'
                }
                onClick={onToggleSendWithoutContext}
                disabled={isComposerDisabled || isComposerAttachmentUploading}
              />
              <Button
                icon={<ThunderboltOutlined />}
                aria-label="즉시 요청"
                onClick={() => {
                  lastReportedDraftRef.current = composerDraft;
                  onDraftChange(composerDraft);
                  onSendImmediate(composerDraft);
                }}
                disabled={isComposerDisabled || isComposerAttachmentUploading}
              />
              <Button
                type="primary"
                icon={<SendOutlined />}
                aria-label="큐로 보내기"
                onClick={() => {
                  lastReportedDraftRef.current = composerDraft;
                  onDraftChange(composerDraft);
                  onSend(composerDraft);
                }}
                disabled={isComposerDisabled || isComposerAttachmentUploading}
              />
            </div>
          </div>
        </div>

        {chatTypeOptions.length === 0 ? (
          <Alert
            showIcon
            type="warning"
            message="사용 가능한 컨텍스트가 없습니다."
            description="관리 페이지에서 현재 사용자 권한에 맞는 컨텍스트를 등록하거나 권한을 부여하세요."
          />
        ) : null}

        {composerAttachmentStrip}

        <div
          className={`app-chat-panel__composer-input-shell${
            queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
          }`}
        >
          {queuedRequests.length > 0 ? (
            <div className="app-chat-panel__composer-queue" aria-live="polite">
              <div className="app-chat-panel__composer-queue-count">
                <span>대기열 {queuedRequests.length}건</span>
              </div>
              <div className="app-chat-panel__composer-queue-list">
                {queuedRequests.map((item) => (
                  <div key={item.requestId} className="app-chat-panel__composer-queue-chip">
                    <div className="app-chat-panel__composer-queue-chip-main">
                      <span className="app-chat-panel__composer-queue-order">{item.order}</span>
                      <span className="app-chat-panel__composer-queue-text">{summarizeQueuedText(item.text)}</span>
                    </div>
                    <div className="app-chat-panel__composer-queue-chip-actions">
                      <Button
                        type="text"
                        size="small"
                        danger
                        icon={<CloseOutlined />}
                        aria-label="대기 요청 취소"
                        onClick={() => {
                          onRemoveQueuedRequest(item.requestId);
                        }}
                      />
                    </div>
                  </div>
                ))}
              </div>
            </div>
          ) : null}

          <Input.TextArea
            ref={composerRef}
            value={composerDraft}
            autoSize={false}
            placeholder={composerPlaceholder}
            disabled={isComposerDisabled}
            onChange={(event) => {
              setComposerDraft(event.target.value);
            }}
            onPaste={handleComposerPaste}
            onKeyDown={(event) => {
              if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
                return;
              }

              const hasSubmitModifier = event.ctrlKey || event.metaKey;
              if (!hasSubmitModifier) {
                event.stopPropagation();
                return;
              }

              if (isComposerAttachmentUploading) {
                return;
              }

              event.preventDefault();
              event.stopPropagation();
              lastReportedDraftRef.current = event.currentTarget.value;
              onDraftChange(event.currentTarget.value);
              onSend(event.currentTarget.value);
            }}
          />
          <Button
            type="text"
            size="small"
            className={`app-chat-panel__composer-clear${composerDraft.trim() ? ' app-chat-panel__composer-clear--visible' : ''}`}
            aria-label="입력창 비우기"
            onClick={onClearDraft}
            disabled={!composerDraft.trim()}
          >
            clear
          </Button>

          <input
            ref={fileInputRef}
            type="file"
            multiple
            accept="image/*,.heic,.heif,.zip,application/zip,application/x-zip-compressed"
            className="app-chat-panel__composer-file-input"
            onChange={handleComposerFileChange}
          />
        </div>
      </div>
      </div>
    </div>
  );
}
