import type { Dispatch, SetStateAction } from 'react';
import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity';
import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess';
import { reportClientError } from '../errorLogApi';
import { notifyNotificationMessagesUpdated } from '../notificationApi';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { resolveConversationUnreadMergeState, resolveStoredConversationUnreadState } from './conversationUnread';
import type {
  ChatActivityEvent,
  ChatConversationActivityLog,
  ChatConversationDetailResponse,
  ChatComposerAttachment,
  ChatMessagePart,
  ChatPromptContextRef,
  ChatConversationRequest,
  ChatConversationSummary,
  ChatShareRoomLinkContext,
  ChatSourceChangeSnapshot,
  ChatSourceChangeSnapshotListResponse,
  ChatJobEvent,
  ChatMessage,
  ChatRuntimeJobDetail,
  ChatRuntimeSnapshot,
  ChatServerEvent,
  ChatViewContext,
} from './types';

const CONNECT_TIMEOUT_MS = 20000;
const CHAT_SESSION_ID_KEY = 'main-chat-panel:session-id';
const CHAT_LAST_EVENT_ID_STORAGE_PREFIX = 'main-chat-panel:last-event-id:';
const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:';
const CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:';
const CHAT_SHARE_ACCESS_PIN_STORAGE_KEY = 'main-chat-panel:share-access-pins';
const CHAT_INTRO_MESSAGE =
  '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요. 여러 Codex를 추가한 즉시 실행은 병렬로 처리됩니다.';
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
const CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 300 * 1024 * 1024;
const KST_TIME_ZONE = 'Asia/Seoul';
const chatSessionLastTypeMemory = new Map<string, string>();
const chatLastEventIdMemory = new Map<string, number>();
const chatOfflineNotificationMemory = new Map<string, boolean>();
const chatShareAccessPinMemory = new Map<string, { pin: string; expiresAtMs: number | null }>();
let chatClientSessionIdMemory = '';
let localMessageSequence = 0;
let chatConversationListRequestPromise: Promise<ChatConversationSummary[]> | null = null;

export class ChatApiError extends Error {
  status: number;
  code: string | null;

  constructor(message: string, status = 500, code?: string | null) {
    super(message);
    this.name = 'ChatApiError';
    this.status = status;
    this.code = code?.trim() || null;
  }
}

export function invalidateChatConversationListCache() {
  chatConversationListRequestPromise = null;
}

function normalizeRequiredText(value: string | null | undefined) {
  return typeof value === 'string' ? value.trim() : '';
}

function normalizeOptionalText(value: string | null | undefined) {
  const normalized = normalizeRequiredText(value);
  return normalized || null;
}

function canUseLocalStorage() {
  return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}

function readStoredChatShareAccessPins() {
  if (!canUseLocalStorage()) {
    return {} as Record<string, { pin: string; expiresAtMs: number | null }>;
  }

  try {
    const rawValue = window.localStorage.getItem(CHAT_SHARE_ACCESS_PIN_STORAGE_KEY);
    if (!rawValue) {
      return {} as Record<string, { pin: string; expiresAtMs: number | null }>;
    }

    const parsed = JSON.parse(rawValue) as Record<string, { pin?: unknown; expiresAtMs?: unknown }>;
    const nowMs = Date.now();
    const nextEntries = Object.entries(parsed).flatMap(([token, value]) => {
      const normalizedToken = normalizeRequiredText(token);
      const normalizedPin = normalizeRequiredText(typeof value?.pin === 'string' ? value.pin : '');
      const expiresAtMs = Number.isFinite(value?.expiresAtMs) ? Number(value.expiresAtMs) : null;

      if (!normalizedToken || !normalizedPin) {
        return [];
      }

      if (expiresAtMs != null && expiresAtMs <= nowMs) {
        return [];
      }

      return [[normalizedToken, { pin: normalizedPin, expiresAtMs }] as const];
    });

    return Object.fromEntries(nextEntries);
  } catch {
    return {} as Record<string, { pin: string; expiresAtMs: number | null }>;
  }
}

function writeStoredChatShareAccessPins(entries: Record<string, { pin: string; expiresAtMs: number | null }>) {
  if (!canUseLocalStorage()) {
    return;
  }

  try {
    const normalizedEntries = Object.entries(entries).flatMap(([token, value]) => {
      const normalizedToken = normalizeRequiredText(token);
      const normalizedPin = normalizeRequiredText(value?.pin);
      const expiresAtMs = Number.isFinite(value?.expiresAtMs) ? Number(value.expiresAtMs) : null;

      if (!normalizedToken || !normalizedPin) {
        return [];
      }

      return [[normalizedToken, { pin: normalizedPin, expiresAtMs }] as const];
    });

    if (normalizedEntries.length === 0) {
      window.localStorage.removeItem(CHAT_SHARE_ACCESS_PIN_STORAGE_KEY);
      return;
    }

    window.localStorage.setItem(
      CHAT_SHARE_ACCESS_PIN_STORAGE_KEY,
      JSON.stringify(Object.fromEntries(normalizedEntries)),
    );
  } catch {
    // Ignore storage failures in restricted runtimes.
  }
}

function removeStoredChatShareAccessPin(token: string) {
  const nextEntries = readStoredChatShareAccessPins();
  delete nextEntries[token];
  writeStoredChatShareAccessPins(nextEntries);
}

export function getStoredChatShareAccessPin(token?: string | null) {
  const normalizedToken = normalizeRequiredText(token);

  if (!normalizedToken) {
    return '';
  }

  const stored = chatShareAccessPinMemory.get(normalizedToken);

  if (!stored) {
    const persisted = readStoredChatShareAccessPins()[normalizedToken];

    if (!persisted) {
      return '';
    }

    chatShareAccessPinMemory.set(normalizedToken, persisted);
    return persisted.pin.trim();
  }

  if (stored.expiresAtMs != null && stored.expiresAtMs <= Date.now()) {
    chatShareAccessPinMemory.delete(normalizedToken);
    removeStoredChatShareAccessPin(normalizedToken);
    return '';
  }

  return stored.pin.trim();
}

export function getStoredChatShareAccessPinExpiryMs(token?: string | null) {
  const normalizedToken = normalizeRequiredText(token);

  if (!normalizedToken) {
    return null;
  }

  const stored = chatShareAccessPinMemory.get(normalizedToken);

  if (!stored) {
    const persisted = readStoredChatShareAccessPins()[normalizedToken];

    if (!persisted) {
      return null;
    }

    chatShareAccessPinMemory.set(normalizedToken, persisted);
    return persisted.expiresAtMs;
  }

  if (stored.expiresAtMs != null && stored.expiresAtMs <= Date.now()) {
    chatShareAccessPinMemory.delete(normalizedToken);
    removeStoredChatShareAccessPin(normalizedToken);
    return null;
  }

  return stored.expiresAtMs;
}

export function setStoredChatShareAccessPin(
  token: string,
  pin?: string | null,
  options?: {
    ttlMinutes?: number | null;
    expiresAt?: string | null;
  },
) {
  const normalizedToken = normalizeRequiredText(token);

  if (!normalizedToken) {
    return;
  }

  const normalizedPin = normalizeRequiredText(pin);

  if (normalizedPin) {
    const expiresAt = normalizeOptionalText(options?.expiresAt);
    const expiresAtMs = expiresAt ? Date.parse(expiresAt) : Number.NaN;
    const ttlMinutes = Number.isFinite(options?.ttlMinutes) ? Math.max(0, Number(options?.ttlMinutes)) : 0;
    const nextEntry = {
      pin: normalizedPin,
      expiresAtMs: Number.isFinite(expiresAtMs)
        ? expiresAtMs
        : ttlMinutes > 0
          ? Date.now() + ttlMinutes * 60 * 1000
          : null,
    };
    chatShareAccessPinMemory.set(normalizedToken, nextEntry);
    const nextEntries = readStoredChatShareAccessPins();
    nextEntries[normalizedToken] = nextEntry;
    writeStoredChatShareAccessPins(nextEntries);
    return;
  }

  chatShareAccessPinMemory.delete(normalizedToken);
  removeStoredChatShareAccessPin(normalizedToken);
}

function extractChatShareTokenFromPath(path: string) {
  const matched = path.match(/^\/shares\/([^/?#]+)/u);
  return matched?.[1] ? decodeURIComponent(matched[1]).trim() : '';
}

type PromptPreview = NonNullable<
  NonNullable<Extract<ChatMessagePart, { type: 'prompt' }>['options'][number]['preview']>
>;
type PromptOption = Extract<ChatMessagePart, { type: 'prompt' }>['options'][number];
type PromptStep = NonNullable<Extract<ChatMessagePart, { type: 'prompt' }>['steps']>[number];

function normalizePromptPreviewType(typeValue: string | null | undefined, url: string, content: string) {
  const normalizedType = normalizeOptionalText(typeValue).toLowerCase();

  if (normalizedType === 'image' || normalizedType === 'markdown' || normalizedType === 'html' || normalizedType === 'resource') {
    return normalizedType;
  }

  if (normalizedType === 'md' || normalizedType === 'text' || normalizedType === 'txt' || normalizedType === 'plain') {
    return 'markdown';
  }

  if (normalizedType === 'htm') {
    return 'html';
  }

  const normalizedContent = normalizeOptionalText(content).trim();
  const normalizedUrl = normalizeOptionalText(url).toLowerCase();

  if (normalizedUrl.endsWith('.md') || normalizedUrl.endsWith('.markdown')) {
    return 'markdown';
  }

  if (normalizedUrl.endsWith('.html') || normalizedUrl.endsWith('.htm')) {
    return 'html';
  }

  if (!normalizedContent) {
    return null;
  }

  if (/<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(normalizedContent)) {
    return 'html';
  }

  if (/^#{1,6}\s|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s+|\[[^\]]+\]\([^)]+\)/m.test(normalizedContent)) {
    return 'markdown';
  }

  return 'resource';
}

function normalizePromptPreview(
  preview: {
    type?: string | null;
    url?: string | null;
    content?: string | null;
    alt?: string | null;
    title?: string | null;
  } | null | undefined,
): PromptPreview | null {
  if (!preview || typeof preview !== 'object') {
    return null;
  }

  const normalizedUrl = normalizeOptionalText(preview.url);
  const normalizedContent = normalizeOptionalText(preview.content);
  const type = normalizePromptPreviewType(preview.type, normalizedUrl, normalizedContent);

  if (!type) {
    return null;
  }

  return {
    type,
    url: normalizedUrl,
    content: normalizedContent,
    alt: normalizeOptionalText(preview.alt),
    title: normalizeOptionalText(preview.title),
  };
}

function normalizePromptOption(
  option:
    | {
        value?: string | null;
        label?: string | null;
        description?: string | null;
        preview?: {
          type?: string | null;
          url?: string | null;
          content?: string | null;
          alt?: string | null;
          title?: string | null;
        } | null;
      }
    | null
    | undefined,
): PromptOption | null {
  if (!option || typeof option !== 'object') {
    return null;
  }

  const value = normalizeRequiredText(option.value);
  const label = normalizeRequiredText(option.label);

  if (!value || !label) {
    return null;
  }

  return {
    value,
    label,
    description: normalizeOptionalText(option.description),
    preview: normalizePromptPreview(option.preview),
  };
}

function normalizePromptStep(
  step:
    | {
        key?: string | null;
        title?: string | null;
        description?: string | null;
        submitLabel?: string | null;
        mode?: 'queue' | 'direct' | null;
        multiple?: boolean;
        optional?: boolean;
        responseTemplate?: string | null;
        freeTextLabel?: string | null;
        freeTextPlaceholder?: string | null;
        selectedValues?: string[];
        options?: Array<{
          value?: string | null;
          label?: string | null;
          description?: string | null;
          preview?: {
            type?: string | null;
            url?: string | null;
            content?: string | null;
            alt?: string | null;
            title?: string | null;
          } | null;
        }> | null;
      }
    | null
    | undefined,
): PromptStep | null {
  if (!step || typeof step !== 'object') {
    return null;
  }

  const key = normalizeRequiredText(step.key);
  const title = normalizeRequiredText(step.title);
  const options = Array.isArray(step.options) ? step.options.flatMap((option) => {
    const normalizedOption = normalizePromptOption(option);
    return normalizedOption ? [normalizedOption] : [];
  }) : [];

  if (!key || !title || options.length === 0) {
    return null;
  }

  return {
    key,
    title,
    description: normalizeOptionalText(step.description),
    submitLabel: normalizeOptionalText(step.submitLabel),
    mode: step.mode === 'direct' ? 'direct' : step.mode === 'queue' ? 'queue' : null,
    multiple: step.multiple === true,
    optional: step.optional === true,
    responseTemplate: normalizeOptionalText(step.responseTemplate),
    freeTextLabel: normalizeOptionalText(step.freeTextLabel),
    freeTextPlaceholder: normalizeOptionalText(step.freeTextPlaceholder),
    selectedValues: Array.isArray(step.selectedValues) ? step.selectedValues.map((value) => normalizeRequiredText(value)).filter(Boolean) : [],
    options,
  };
}

function normalizeChatMessagePart(part: ChatMessagePart | null | undefined): ChatMessagePart | null {
  if (!part || typeof part !== 'object') {
    return null;
  }

  if (part.type === 'link_card') {
    const title = normalizeRequiredText(part.title);
    const url = normalizeRequiredText(part.url);

    if (!title || !url) {
      return null;
    }

    return {
      type: 'link_card',
      title,
      url,
      actionLabel: normalizeOptionalText(part.actionLabel),
    } satisfies Extract<ChatMessagePart, { type: 'link_card' }>;
  }

  if (part.type !== 'prompt') {
    return null;
  }

  const title = normalizeRequiredText(part.title);
  const options = Array.isArray(part.options) ? part.options.flatMap((option) => {
    const normalizedOption = normalizePromptOption(option);
    return normalizedOption ? [normalizedOption] : [];
  }) : [];
  const steps = Array.isArray(part.steps) ? part.steps.flatMap((step) => {
    const normalizedStep = normalizePromptStep(step);
    return normalizedStep ? [normalizedStep] : [];
  }) : [];

  if (!title || (options.length === 0 && steps.length === 0)) {
    return null;
  }

  return {
    type: 'prompt',
    title,
    description: normalizeOptionalText(part.description),
    submitLabel: normalizeOptionalText(part.submitLabel),
    mode: part.mode === 'direct' ? 'direct' : part.mode === 'queue' ? 'queue' : null,
    multiple: part.multiple === true,
    responseTemplate: normalizeOptionalText(part.responseTemplate),
    freeTextLabel: normalizeOptionalText(part.freeTextLabel),
    freeTextPlaceholder: normalizeOptionalText(part.freeTextPlaceholder),
    currentStepKey: normalizeOptionalText(part.currentStepKey),
    steps,
    readOnly: part.readOnly === true,
    selectedValues: Array.isArray(part.selectedValues) ? part.selectedValues.map((value) => normalizeRequiredText(value)).filter(Boolean) : [],
    resolvedBy:
      part.resolvedBy === 'user' || part.resolvedBy === 'timeout' || part.resolvedBy === 'system' ? part.resolvedBy : null,
    resolvedAt: normalizeOptionalText(part.resolvedAt),
    resultText: normalizeOptionalText(part.resultText),
    options,
  };
}

function normalizeChatMessage(message: ChatMessage, fallbackIndex = 0): ChatMessage {
  const author = message.author === 'codex' || message.author === 'user' || message.author === 'system' ? message.author : 'system';
  const normalizedId = Number.isFinite(message.id) ? Number(message.id) : -(fallbackIndex + 1);

  return {
    ...message,
    id: normalizedId,
    author,
    text: typeof message.text === 'string' ? message.text : '',
    timestamp: typeof message.timestamp === 'string' ? message.timestamp : '',
    clientRequestId: normalizeOptionalText(message.clientRequestId),
    deliveryStatus: message.deliveryStatus === 'failed' || message.deliveryStatus === 'retrying' ? message.deliveryStatus : null,
    retryCount: Number.isFinite(message.retryCount) ? Math.max(0, Math.round(Number(message.retryCount))) : 0,
    parts: Array.isArray(message.parts)
      ? message.parts.flatMap((part) => {
          const normalizedPart = normalizeChatMessagePart(part);
          return normalizedPart ? [normalizedPart] : [];
        })
      : [],
  };
}

function toConversationSortTime(value: string | null | undefined) {
  if (typeof value !== 'string' || !value.trim()) {
    return 0;
  }

  const parsed = Date.parse(value);
  return Number.isNaN(parsed) ? 0 : parsed;
}

function getConversationLastMessageSortTime(item: ChatConversationSummary) {
  const lastMessageTime = toConversationSortTime(item.lastMessageAt);

  if (lastMessageTime > 0) {
    return lastMessageTime;
  }

  return Math.max(
    toConversationSortTime(item.createdAt),
    toConversationSortTime(item.updatedAt),
  );
}

function pickPreferredConversationSummary(
  left: ChatConversationSummary,
  right: ChatConversationSummary,
) {
  const leftTime = getConversationLastMessageSortTime(left);
  const rightTime = getConversationLastMessageSortTime(right);

  if (rightTime !== leftTime) {
    return rightTime > leftTime ? right : left;
  }

  const leftUpdatedAt = toConversationSortTime(left.updatedAt);
  const rightUpdatedAt = toConversationSortTime(right.updatedAt);

  if (rightUpdatedAt !== leftUpdatedAt) {
    return rightUpdatedAt > leftUpdatedAt ? right : left;
  }

  return right;
}

function mergeConversationSummaries(
  existing: ChatConversationSummary,
  incoming: ChatConversationSummary,
) {
  const preferred = pickPreferredConversationSummary(existing, incoming);
  const fallback = preferred === existing ? incoming : existing;

  return {
    ...fallback,
    ...preferred,
    clientId: preferred.clientId ?? fallback.clientId,
    isDraftOnly: preferred.isDraftOnly ?? fallback.isDraftOnly,
    draftText: preferred.draftText ?? fallback.draftText ?? '',
    title: preferred.title.trim() || fallback.title.trim(),
    requestBadgeLabel: preferred.requestBadgeLabel?.trim() || fallback.requestBadgeLabel?.trim() || null,
    codexModel: preferred.codexModel?.trim() || fallback.codexModel?.trim() || null,
    chatTypeId: preferred.chatTypeId?.trim() || fallback.chatTypeId?.trim() || null,
    lastChatTypeId: preferred.lastChatTypeId?.trim() || fallback.lastChatTypeId?.trim() || null,
    generalSectionName: preferred.generalSectionName?.trim() || fallback.generalSectionName?.trim() || null,
    contextLabel: preferred.contextLabel?.trim() || fallback.contextLabel?.trim() || null,
    contextDescription: preferred.contextDescription?.trim() || fallback.contextDescription?.trim() || null,
    roomScope: preferred.roomScope ?? fallback.roomScope ?? null,
    notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
    hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
    hasPendingAttention: preferred.hasPendingAttention === true,
    currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null,
    currentJobStatus: preferred.currentJobStatus ?? fallback.currentJobStatus,
    currentJobMessage: preferred.currentJobMessage?.trim() || fallback.currentJobMessage?.trim() || null,
    currentQueueSize: Math.max(preferred.currentQueueSize ?? 0, fallback.currentQueueSize ?? 0),
    currentStatusUpdatedAt:
      preferred.currentStatusUpdatedAt?.trim() || fallback.currentStatusUpdatedAt?.trim() || null,
    isPendingWork: preferred.isPendingWork ?? fallback.isPendingWork,
    pendingWorkReason: preferred.pendingWorkReason ?? fallback.pendingWorkReason,
    lastRequestPreview: preferred.lastRequestPreview.trim() || fallback.lastRequestPreview.trim(),
    lastMessagePreview: preferred.lastMessagePreview.trim() || fallback.lastMessagePreview.trim(),
    lastResponsePreview: preferred.lastResponsePreview.trim() || fallback.lastResponsePreview.trim(),
    createdAt: preferred.createdAt.trim() || fallback.createdAt.trim(),
    updatedAt: preferred.updatedAt.trim() || fallback.updatedAt.trim(),
    lastMessageAt: preferred.lastMessageAt?.trim() || fallback.lastMessageAt?.trim() || null,
  };
}

export function sortChatConversationSummaries(items: ChatConversationSummary[]) {
  const dedupedItems = items.reduce<ChatConversationSummary[]>((result, item) => {
    const sessionId = item.sessionId.trim();

    if (!sessionId) {
      result.push(item);
      return result;
    }

    const existingIndex = result.findIndex((candidate) => candidate.sessionId.trim() === sessionId);

    if (existingIndex < 0) {
      result.push(item);
      return result;
    }

    const nextItems = [...result];
    nextItems[existingIndex] = mergeConversationSummaries(nextItems[existingIndex] as ChatConversationSummary, item);
    return nextItems;
  }, []);

  return dedupedItems.sort((left, right) => {
    const leftTime = getConversationLastMessageSortTime(left);
    const rightTime = getConversationLastMessageSortTime(right);

    if (rightTime !== leftTime) {
      return rightTime - leftTime;
    }

    return left.sessionId.localeCompare(right.sessionId, 'ko-KR');
  });
}

export function getDefaultRequestStatusMessage(status: ChatConversationRequest['status']) {
  switch (status) {
    case 'accepted':
      return '요청을 접수했습니다.';
    case 'queued':
      return '대기열 등록';
    case 'started':
      return '요청 처리 중';
    case 'completed':
      return '요청 처리 완료';
    case 'failed':
      return '요청 처리 실패';
    case 'cancelled':
      return '요청 실행 중단';
    case 'removed':
      return '요청 기록이 제거되었습니다.';
    default:
      return null;
  }
}

export function mergeConversationRequestStatusMessage(
  previousItem: Pick<ChatConversationRequest, 'status' | 'statusMessage'> | null | undefined,
  nextItem: Pick<ChatConversationRequest, 'status' | 'statusMessage'>,
) {
  const nextStatusMessage = nextItem.statusMessage?.trim() || '';

  if (nextStatusMessage) {
    return nextStatusMessage;
  }

  if (!previousItem || previousItem.status !== nextItem.status) {
    return getDefaultRequestStatusMessage(nextItem.status);
  }

  return previousItem.statusMessage?.trim() || getDefaultRequestStatusMessage(nextItem.status);
}

export const CHAT_CONNECTION = {
  reconnectDelayMs: 1500,
  connectTimeoutMs: CONNECT_TIMEOUT_MS,
  sessionIdKey: CHAT_SESSION_ID_KEY,
  lastEventIdStoragePrefix: CHAT_LAST_EVENT_ID_STORAGE_PREFIX,
  notifyOfflineStoragePrefix: CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX,
  sessionLastTypeStoragePrefix: CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX,
  introMessage: CHAT_INTRO_MESSAGE,
} as const;

function buildNotifyOfflineStorageKey(sessionId: string, clientId?: string | null) {
  const normalizedSessionId = sessionId.trim() || 'default';
  const normalizedClientId = clientId?.trim() || getOrCreateClientId() || 'default-client';
  return `${CHAT_CONNECTION.notifyOfflineStoragePrefix}${normalizedSessionId}:${normalizedClientId}`;
}

function createBrowserSessionId() {
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
    return crypto.randomUUID();
  }

  return `chat-session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

export function clearStoredChatClientConversationState() {
  if (typeof window === 'undefined') {
    return;
  }

  chatClientSessionIdMemory = '';
  chatSessionLastTypeMemory.clear();
  chatLastEventIdMemory.clear();
  chatOfflineNotificationMemory.clear();
}

function normalizeChatConversationRequest(item: ChatConversationRequest): ChatConversationRequest {
  const hasResponse = item.hasResponse === true;
  const usageSnapshot =
    item.usageSnapshot && typeof item.usageSnapshot === 'object'
      ? {
          tokenTotals: {
            total: Math.max(0, Math.round(Number(item.usageSnapshot.tokenTotals?.total ?? 0) || 0)),
            input: Math.max(0, Math.round(Number(item.usageSnapshot.tokenTotals?.input ?? 0) || 0)),
            output: Math.max(0, Math.round(Number(item.usageSnapshot.tokenTotals?.output ?? 0) || 0)),
            cached: Math.max(0, Math.round(Number(item.usageSnapshot.tokenTotals?.cached ?? 0) || 0)),
            reasoning: Math.max(0, Math.round(Number(item.usageSnapshot.tokenTotals?.reasoning ?? 0) || 0)),
          },
          totalTokens: Math.max(0, Math.round(Number(item.usageSnapshot.totalTokens ?? 0) || 0)),
        }
      : null;
  const promptContextRef =
    item.promptContextRef?.key === 'prompt_parent_question' && normalizeRequiredText(item.promptContextRef.promptTitle)
      ? {
          key: 'prompt_parent_question' as const,
          promptTitle: normalizeRequiredText(item.promptContextRef.promptTitle),
          promptDescription: normalizeOptionalText(item.promptContextRef.promptDescription),
          parentQuestionText: normalizeOptionalText(item.promptContextRef.parentQuestionText),
        }
      : null;

  return {
    ...item,
    sessionId: normalizeRequiredText(item.sessionId),
    requestId: normalizeRequiredText(item.requestId),
    requesterClientId: normalizeOptionalText(item.requesterClientId),
    chatTypeId: normalizeOptionalText(item.chatTypeId),
    chatTypeLabel: normalizeRequiredText(item.chatTypeLabel),
    parentRequestId: normalizeOptionalText(item.parentRequestId),
    promptContextRef,
    statusMessage: normalizeOptionalText(item.statusMessage),
    retryCount: Number.isFinite(Number(item.retryCount)) ? Math.max(0, Math.round(Number(item.retryCount))) : 0,
    userText: normalizeRequiredText(item.userText),
    responseText: normalizeRequiredText(item.responseText),
    usageSnapshot,
    totalTokens:
      item.totalTokens == null ? null : Math.max(0, Math.round(Number(item.totalTokens) || 0)),
    hasResponse,
    canDelete:
      item.canDelete === true || (!hasResponse && item.status !== 'queued' && item.status !== 'started' && item.status !== 'removed'),
    manualPromptCompletedAt: normalizeOptionalText(item.manualPromptCompletedAt),
    manualVerificationCompletedAt: normalizeOptionalText(item.manualVerificationCompletedAt),
    createdAt: normalizeRequiredText(item.createdAt),
    updatedAt: normalizeRequiredText(item.updatedAt),
    answeredAt: normalizeOptionalText(item.answeredAt),
    terminalAt: normalizeOptionalText(item.terminalAt),
  };
}

export function isPreparingChatReplyText(text?: string | null) {
  const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
  return normalized.startsWith('응답을 준비하고 있습니다');
}

function shouldPreserveExistingCodexMessageText(existingMessage: ChatMessage, incomingMessage: ChatMessage) {
  if (existingMessage.author !== 'codex' || incomingMessage.author !== 'codex') {
    return false;
  }

  const existingText = String(existingMessage.text ?? '').trim();
  const incomingText = String(incomingMessage.text ?? '').trim();

  if (!existingText || !incomingText) {
    return false;
  }

  return !isPreparingChatReplyText(existingText) && isPreparingChatReplyText(incomingText);
}

export function getChatClientSessionId() {
  if (typeof window === 'undefined') {
    return '';
  }

  if (chatClientSessionIdMemory) {
    return chatClientSessionIdMemory;
  }

  chatClientSessionIdMemory = createBrowserSessionId();
  return chatClientSessionIdMemory;
}

export function setChatClientSessionId(sessionId: string) {
  if (typeof window === 'undefined') {
    return;
  }

  const normalizedSessionId = sessionId.trim();

  if (!normalizedSessionId) {
    return;
  }

  chatClientSessionIdMemory = normalizedSessionId;
}

export function getLastReceivedChatEventId(sessionId: string) {
  if (typeof window === 'undefined') {
    return 0;
  }

  const normalizedSessionId = sessionId.trim();

  if (!normalizedSessionId) {
    return 0;
  }

  return chatLastEventIdMemory.get(normalizedSessionId) ?? 0;
}

export function persistLastReceivedChatEventId(sessionId: string, eventId: number) {
  if (typeof window === 'undefined' || !Number.isFinite(eventId) || eventId <= 0) {
    return;
  }

  const normalizedSessionId = sessionId.trim();

  if (!normalizedSessionId) {
    return;
  }

  const currentEventId = getLastReceivedChatEventId(normalizedSessionId);

  if (eventId <= currentEventId) {
    return;
  }

  chatLastEventIdMemory.set(normalizedSessionId, eventId);
}

export function resetLastReceivedChatEventId(sessionId: string) {
  if (typeof window === 'undefined') {
    return;
  }

  const normalizedSessionId = sessionId.trim();

  if (!normalizedSessionId) {
    return;
  }

  chatLastEventIdMemory.delete(normalizedSessionId);
}

export function getStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) {
  if (typeof window === 'undefined') {
    return null;
  }

  const key = buildNotifyOfflineStorageKey(sessionId, clientId);

  if (!chatOfflineNotificationMemory.has(key)) {
    return null;
  }

  return chatOfflineNotificationMemory.get(key) ?? null;
}

export function setStoredChatOfflineNotificationSetting(sessionId: string, enabled: boolean, clientId?: string | null) {
  if (typeof window === 'undefined') {
    return;
  }

  chatOfflineNotificationMemory.set(buildNotifyOfflineStorageKey(sessionId, clientId), enabled);
}

export function clearStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) {
  if (typeof window === 'undefined') {
    return;
  }

  chatOfflineNotificationMemory.delete(buildNotifyOfflineStorageKey(sessionId, clientId));
}

function resolveSyncedChatOfflineNotificationSetting(
  sessionId: string,
  serverValue: boolean,
  clientId?: string | null,
) {
  const storedValue = getStoredChatOfflineNotificationSetting(sessionId, clientId);

  if (storedValue == null) {
    setStoredChatOfflineNotificationSetting(sessionId, serverValue, clientId);
    return serverValue;
  }

  if (storedValue !== serverValue) {
    setStoredChatOfflineNotificationSetting(sessionId, serverValue, clientId);
  }

  return serverValue;
}

export function getStoredChatSessionLastTypeId(sessionId: string) {
  const normalizedSessionId = sessionId.trim();

  if (!normalizedSessionId) {
    return null;
  }

  const raw = chatSessionLastTypeMemory.get(normalizedSessionId)?.trim() ?? '';
  return raw || null;
}

export function setStoredChatSessionLastTypeId(sessionId: string, chatTypeId: string) {
  const normalizedSessionId = sessionId.trim();
  const normalizedChatTypeId = chatTypeId.trim();

  if (!normalizedSessionId) {
    return;
  }

  if (!normalizedChatTypeId) {
    chatSessionLastTypeMemory.delete(normalizedSessionId);
    return;
  }

  chatSessionLastTypeMemory.set(normalizedSessionId, normalizedChatTypeId);
}

export function formatTime(date: Date) {
  return 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,
  })
    .format(date)
    .replace(',', '');
}

function createLocalMessageId() {
  localMessageSequence = (localMessageSequence + 1) % 1_000;
  return Date.now() * 1_000 + localMessageSequence;
}

function createRecoveredMessageId(
  requestId: string,
  variant: 'user' | 'codex' | 'activity' | 'missing-request' | 'execution-failure',
) {
  const baseId = hashRequestId(requestId) * 10;

  if (variant === 'user') {
    return -(baseId + 3);
  }

  if (variant === 'missing-request') {
    return -(baseId + 2);
  }

  if (variant === 'activity') {
    return -(baseId + 1);
  }

  if (variant === 'execution-failure') {
    return -(baseId + 4);
  }

  return -(baseId + 5);
}

function hashRequestId(value: string) {
  let hash = 0;

  for (const character of value) {
    hash = (hash * 31 + character.charCodeAt(0)) | 0;
  }

  return Math.abs(hash) + 1_000_000;
}

export function createLocalMessage(text: string): ChatMessage {
  return {
    id: createLocalMessageId(),
    author: 'system',
    text,
    timestamp: formatTime(new Date()),
  };
}

export function createChatMessage(author: ChatMessage['author'], text: string, clientRequestId?: string | null): ChatMessage {
  return {
    id: createLocalMessageId(),
    author,
    text,
    timestamp: formatTime(new Date()),
    clientRequestId: clientRequestId?.trim() || null,
  };
}

export function createActivityLogPlaceholder(requestId: string, lines?: string[]) {
  const normalizedRequestId = requestId.trim();

  if (!normalizedRequestId) {
    return null;
  }

  const normalizedLines = (lines ?? []).map((line) => line.trim()).filter(Boolean);
  const fallbackLine = '요청을 접수했습니다. 활동 로그를 준비하고 있습니다.';

  return {
    id: hashRequestId(normalizedRequestId),
    author: 'system' as const,
    text: `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${(normalizedLines.length > 0 ? normalizedLines : [fallbackLine]).join('\n\n')}`,
    timestamp: formatTime(new Date()),
    clientRequestId: normalizedRequestId,
  };
}

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

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

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

function isEmptyCodexExecutionResponse(text: string) {
  const normalized = text.replace(/\s+/g, ' ').trim();
  return normalized === 'Codex 실행 결과가 비어 있습니다.';
}

function extractActivityLogFailureReason(lines?: string[] | null) {
  const normalizedLines = (lines ?? []).map((line) => line.trim()).filter(Boolean);

  for (let index = normalizedLines.length - 1; index >= 0; index -= 1) {
    const line = normalizedLines[index];

    if (!line.startsWith('# 오류:')) {
      continue;
    }

    const raw = line.slice('# 오류:'.length).trim();

    if (!raw) {
      continue;
    }

    try {
      const parsed = JSON.parse(raw) as { message?: unknown };
      const message = typeof parsed.message === 'string' ? parsed.message.trim() : '';

      if (message) {
        return message;
      }
    } catch {
      return raw;
    }
  }

  return '';
}

function buildExecutionFailureMessage(reason: string) {
  const normalizedReason = reason.trim();

  if (!normalizedReason) {
    return `${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n실행 중 오류가 발생했습니다.`;
  }

  const simplifiedReason = normalizedReason.includes("mkdir '") && normalizedReason.includes('/resource/uploads')
    ? `세션 리소스 업로드 폴더를 만들 권한이 없어 응답 생성이 중단되었습니다.\n\n원인: ${normalizedReason}`
    : `실행 중 오류가 발생했습니다.\n\n원인: ${normalizedReason}`;

  return `${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n${simplifiedReason}`;
}

function buildFailurePreviewText(reason: string) {
  const normalizedReason = reason.trim();

  if (!normalizedReason) {
    return '실행 실패';
  }

  if (normalizedReason.includes("mkdir '") && normalizedReason.includes('/resource/uploads')) {
    return '실행 실패: 세션 리소스 업로드 폴더 권한 오류';
  }

  return `실행 실패: ${normalizedReason}`;
}

function enrichFailedRequestsWithActivityLogs(
  requests: ChatConversationRequest[],
  activityLogs: ChatConversationActivityLog[],
) {
  const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item]));

  return requests.map((request) => {
    if (request.status !== 'failed') {
      return request;
    }

    const activityLog = activityLogMap.get(request.requestId.trim());
    const failureReason = extractActivityLogFailureReason(activityLog?.lines);
    const normalizedStatusMessage = String(request.statusMessage ?? '').trim();

    if (!failureReason) {
      return request;
    }

    if (normalizedStatusMessage && normalizedStatusMessage !== '요청 처리 실패') {
      return request;
    }

    return {
      ...request,
      statusMessage: failureReason,
    };
  });
}

function replaceGenericFailureMessages(
  messages: ChatMessage[],
  requests: ChatConversationRequest[],
  activityLogs: ChatConversationActivityLog[],
): ChatMessage[] {
  const requestMap = new Map(requests.map((item) => [item.requestId.trim(), item]));
  const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item]));

  return messages.map((message) => {
    const requestId = message.clientRequestId?.trim() ?? '';

    if (!requestId || message.author !== 'codex' || !isEmptyCodexExecutionResponse(message.text)) {
      return message;
    }

    const request = requestMap.get(requestId);

    if (request?.status !== 'failed') {
      return message;
    }

    const failureReason = extractActivityLogFailureReason(activityLogMap.get(requestId)?.lines);

    if (!failureReason) {
      return message;
    }

    return {
      ...message,
      author: 'system' as const,
      text: buildExecutionFailureMessage(failureReason),
    };
  });
}

function resolveConversationFailurePreview(
  currentPreview: string,
  requests: ChatConversationRequest[],
  activityLogs: ChatConversationActivityLog[],
) {
  if (!isEmptyCodexExecutionResponse(currentPreview)) {
    return currentPreview;
  }

  const latestFailedRequest = [...requests]
    .reverse()
    .find((request) => request.status === 'failed' && isEmptyCodexExecutionResponse(String(request.responseText ?? '').trim()));

  if (!latestFailedRequest) {
    return currentPreview;
  }

  const activityLog = activityLogs.find((item) => item.requestId.trim() === latestFailedRequest.requestId.trim());
  const failureReason = extractActivityLogFailureReason(activityLog?.lines);

  if (!failureReason) {
    return currentPreview;
  }

  return buildFailurePreviewText(failureReason);
}

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

function getActivityLogLines(message?: ChatMessage) {
  if (!message || !isActivityLogMessage(message)) {
    return [];
  }

  return extractActivityLogLines(message.text);
}

function mergeActivityLines(existingLines: string[], incomingLines: string[]) {
  const merged: string[] = [];

  for (const line of [...existingLines, ...incomingLines]) {
    const normalized = line.trim();

    if (!normalized || merged.at(-1) === normalized) {
      continue;
    }

    merged.push(normalized);
  }

  return merged;
}

function mergeActivityLineAtPosition(existingLines: string[], incomingLine: string, lineNo?: number) {
  const normalizedLine = incomingLine.trim();

  if (!normalizedLine) {
    return existingLines;
  }

  if (!Number.isInteger(lineNo) || Number(lineNo) <= 0) {
    return mergeActivityLines(existingLines, [normalizedLine]);
  }

  const nextLines = [...existingLines];
  nextLines[Number(lineNo) - 1] = normalizedLine;
  return nextLines.filter(Boolean);
}

function buildActivityMessageIndex(messages: ChatMessage[]) {
  const indexByRequestId = new Map<string, number>();

  messages.forEach((message, index) => {
    const requestId = message.clientRequestId?.trim();

    if (!requestId || !isActivityLogMessage(message)) {
      return;
    }

    indexByRequestId.set(requestId, index);
  });

  return indexByRequestId;
}

function findActivityAnchorIndex(messages: ChatMessage[], requestId: string) {
  let fallbackUserIndex = -1;

  for (let index = 0; index < messages.length; index += 1) {
    const message = messages[index];

    if (message.clientRequestId?.trim() !== requestId) {
      continue;
    }

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

    if (message.author === 'user') {
      fallbackUserIndex = index;
    }
  }

  return fallbackUserIndex >= 0 ? fallbackUserIndex + 1 : messages.length;
}

function upsertActivityLogMessage(messages: ChatMessage[], activityMessage: ChatMessage) {
  const requestId = activityMessage.clientRequestId?.trim();

  if (!requestId) {
    return messages;
  }

  const existingIndex = messages.findIndex(
    (message) => isActivityLogMessage(message) && message.clientRequestId?.trim() === requestId,
  );

  if (existingIndex >= 0) {
    const nextMessages = [...messages];
    nextMessages[existingIndex] = activityMessage;
    return nextMessages;
  }

  const insertIndex = findActivityAnchorIndex(messages, requestId);
  return [...messages.slice(0, insertIndex), activityMessage, ...messages.slice(insertIndex)];
}

export function hydrateActivityLogMessages(messages: ChatMessage[], activityLogs: ChatConversationActivityLog[]) {
  if (activityLogs.length === 0) {
    return messages;
  }

  let nextMessages = [...messages];

  for (const activityLog of activityLogs) {
    const requestId = activityLog.requestId?.trim();

    if (!requestId) {
      continue;
    }

    const placeholder = createActivityLogPlaceholder(requestId, activityLog.lines);

    if (!placeholder) {
      continue;
    }

    const existingMessage = nextMessages.find(
      (message) => isActivityLogMessage(message) && message.clientRequestId?.trim() === requestId,
    );
    const mergedLines = mergeActivityLines(getActivityLogLines(existingMessage), activityLog.lines);
    const nextActivityMessage = {
      ...(existingMessage ?? placeholder),
      ...placeholder,
      text: createActivityLogPlaceholder(requestId, mergedLines)?.text ?? placeholder.text,
      timestamp: activityLog.updatedAt?.trim() || existingMessage?.timestamp || placeholder.timestamp,
    };
    nextMessages = upsertActivityLogMessage(nextMessages, nextActivityMessage);
  }

  return nextMessages;
}

export function appendActivityEventToMessages(previous: ChatMessage[], event: ChatActivityEvent) {
  const requestId = event.requestId.trim();

  if (!requestId) {
    return previous;
  }

  const activityMessageIndex = buildActivityMessageIndex(previous);
  const existingIndex = activityMessageIndex.get(requestId);
  const existingMessage = existingIndex == null ? undefined : previous[existingIndex];
  const mergedLines = mergeActivityLineAtPosition(getActivityLogLines(existingMessage), event.line, event.lineNo);
  const nextMessage = createActivityLogPlaceholder(requestId, mergedLines);

  if (!nextMessage) {
    return previous;
  }

  const nextActivityMessage =
    existingIndex == null
      ? nextMessage
      : {
          ...existingMessage,
          ...nextMessage,
        };
  return upsertActivityLogMessage(previous, nextActivityMessage);
}

export function createIntroMessage(chatTypeLabel?: string, chatTypeDescription?: string) {
  const normalizedChatTypeLabel = chatTypeLabel?.trim() ?? '';
  const contextLabelLine =
    normalizedChatTypeLabel && normalizedChatTypeLabel !== '일반 요청' && normalizedChatTypeLabel !== '기본처리'
      ? `선택 컨텍스트: ${normalizedChatTypeLabel}`
      : '';
  const contextDescriptionLine = chatTypeDescription ? `기본 문맥: ${chatTypeDescription}` : '';

  return createChatMessage(
    'codex',
    [CHAT_CONNECTION.introMessage, contextLabelLine, contextDescriptionLine].filter(Boolean).join('\n'),
  );
}

export function buildOfflineReply(context: ChatViewContext, input: string) {
  const normalized = input.toLowerCase();
  const normalizedChatTypeLabel = context.chatTypeLabel?.trim() ?? '';
  const typeLine =
    normalizedChatTypeLabel && normalizedChatTypeLabel !== '일반 요청' && normalizedChatTypeLabel !== '기본처리'
      ? `- 컨텍스트: ${normalizedChatTypeLabel}`
      : '';
  const descriptionLine = context.chatTypeDescription ? `- 기본 문맥: ${context.chatTypeDescription}` : '';

  if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) {
    return ['결과', `- preview: ${context.pageUrl}`, typeLine, descriptionLine].filter(Boolean).join('\n');
  }

  if (input.includes('계획') || normalized.includes('plan')) {
    return ['결과', '- Plan 상세 조회는 서버 연결 후 가능합니다.', typeLine, descriptionLine].filter(Boolean).join('\n');
  }

  return ['결과', `- 현재 화면: ${context.pageTitle}`, '- 서버 연결 후 더 정확한 정보를 줄 수 있습니다.', typeLine, descriptionLine]
    .filter(Boolean)
    .join('\n');
}

export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number, clientId?: string, shareToken?: string) {
  const configuredBaseUrl = import.meta.env.VITE_WORK_SERVER_URL;
  const resolvedClientId = clientId || getOrCreateClientId();
  const accessToken = getRegisteredAccessToken();
  const normalizedShareToken = shareToken?.trim() || '';
  const storedSharePin = normalizedShareToken ? getStoredChatShareAccessPin(normalizedShareToken) : '';

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

  if (configuredBaseUrl) {
    const normalizedUrl = new URL(configuredBaseUrl, window.location.origin);
    normalizedUrl.protocol = normalizedUrl.protocol === 'https:' ? 'wss:' : 'ws:';
    const trimmedPathname = normalizedUrl.pathname.replace(/\/+$/, '');
    normalizedUrl.pathname = trimmedPathname.endsWith('/api')
      ? `${trimmedPathname.slice(0, -4) || ''}/ws/chat`
      : `${trimmedPathname || ''}/ws/chat`;
    normalizedUrl.search = '';
    normalizedUrl.hash = '';
    if (sessionId) {
      normalizedUrl.searchParams.set('sessionId', sessionId);
    }
    if (resolvedClientId) {
      normalizedUrl.searchParams.set('clientId', resolvedClientId);
    }
    if (accessToken) {
      normalizedUrl.searchParams.set('accessToken', accessToken);
    }
    if (normalizedShareToken) {
      normalizedUrl.searchParams.set('shareToken', normalizedShareToken);
    }
    if (storedSharePin) {
      normalizedUrl.searchParams.set('sharePin', storedSharePin);
    }
    if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) {
      normalizedUrl.searchParams.set('lastEventId', String(lastEventId));
    }
    return normalizedUrl.toString();
  }

  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  const url = new URL(`${protocol}//${window.location.host}/ws/chat`);

  if (sessionId) {
    url.searchParams.set('sessionId', sessionId);
  }
  if (resolvedClientId) {
    url.searchParams.set('clientId', resolvedClientId);
  }
  if (accessToken) {
    url.searchParams.set('accessToken', accessToken);
  }
  if (normalizedShareToken) {
    url.searchParams.set('shareToken', normalizedShareToken);
  }
  if (storedSharePin) {
    url.searchParams.set('sharePin', storedSharePin);
  }
  if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) {
    url.searchParams.set('lastEventId', String(lastEventId));
  }

  return url.toString();
}

function resolveWorkServerHealthUrl() {
  const configuredBaseUrl = import.meta.env.VITE_WORK_SERVER_URL;

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

  const normalizedUrl = configuredBaseUrl
    ? new URL(configuredBaseUrl, window.location.origin)
    : new URL(window.location.origin);
  const trimmedPathname = normalizedUrl.pathname.replace(/\/+$/, '');
  normalizedUrl.pathname = trimmedPathname.endsWith('/api')
    ? `${trimmedPathname.slice(0, -4) || ''}/health`
    : `${trimmedPathname || ''}/health`;
  normalizedUrl.search = '';
  normalizedUrl.hash = '';
  return normalizedUrl.toString();
}

export async function diagnoseConnectionFailure(targetUrl: string, closeEvent?: CloseEvent) {
  const diagnostics: string[] = [];

  if (typeof navigator !== 'undefined' && !navigator.onLine) {
    diagnostics.push('브라우저가 오프라인 상태입니다.');
  }

  if (typeof window !== 'undefined' && window.location.protocol === 'https:' && targetUrl.startsWith('ws://')) {
    diagnostics.push('HTTPS 화면에서 비보안 `ws://` 연결이 차단되었을 수 있습니다.');
  }

  if (closeEvent) {
    diagnostics.push(`종료 코드 ${closeEvent.code}${closeEvent.reason ? ` (${closeEvent.reason})` : ''}`);
  }

  const healthUrl = resolveWorkServerHealthUrl();

  if (healthUrl) {
    try {
      const response = await fetch(healthUrl, {
        cache: 'no-store',
      });

      if (response.ok) {
        diagnostics.push(`헬스체크 성공: ${healthUrl}`);
        diagnostics.push(`HTTP 서버는 응답 중입니다. WebSocket 연결 대상: ${targetUrl}`);
        diagnostics.push('WebSocket 업그레이드 또는 프록시 설정을 확인해 주세요.');
      } else {
        diagnostics.push(`헬스체크 실패: ${response.status} ${response.statusText}`);
      }
    } catch (error) {
      diagnostics.push(
        `헬스체크 요청 실패: ${error instanceof Error ? error.message : '서버에 도달하지 못했습니다.'}`,
      );
    }
  }

  return diagnostics.join(' / ');
}

export async function copyText(text: string) {
  return copyTextToClipboard(text);
}

export type PreviewShareResult = 'shared' | 'copied';

export async function sharePreviewLink({
  url,
  title,
  text,
}: {
  url: string;
  title?: string | null;
  text?: string | null;
}): Promise<PreviewShareResult> {
  const normalizedUrl = String(url ?? '').trim();

  if (!normalizedUrl) {
    throw new Error('공유할 preview 링크가 없습니다.');
  }

  if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
    try {
      await navigator.share({
        url: normalizedUrl,
        title: title?.trim() || undefined,
        text: text?.trim() || undefined,
      });
      return 'shared';
    } catch (error) {
      if (error instanceof DOMException && error.name === 'AbortError') {
        throw error;
      }
    }
  }

  await copyText(normalizedUrl);
  return 'copied';
}

export type PreviewCopyResult = 'text' | 'image' | 'url';

async function copyImagePreview(url: string): Promise<PreviewCopyResult> {
  const response = await fetch(url, {
    cache: 'no-store',
  });

  if (!response.ok) {
    throw new Error(`preview 이미지를 가져오지 못했습니다. (${response.status})`);
  }

  const imageBlob = await response.blob();

  if (!imageBlob.type.startsWith('image/')) {
    throw new Error('이미지 preview만 이미지 자체를 복사할 수 있습니다.');
  }

  if (typeof navigator !== 'undefined' && navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
    await navigator.clipboard.write([
      new ClipboardItem({
        [imageBlob.type]: imageBlob,
      }),
    ]);
    return 'image';
  }

  await copyText(url);
  return 'url';
}

function canCopyPreviewBody(kind: string | null | undefined) {
  return !['image', 'video', 'pdf', 'file'].includes(String(kind ?? '').trim().toLowerCase());
}

export async function copyPreviewContent({
  kind,
  url,
  fallbackText,
}: {
  kind: string | null | undefined;
  url: string;
  fallbackText?: string | null;
}): Promise<PreviewCopyResult> {
  const normalizedKind = String(kind ?? '').trim().toLowerCase();

  if (normalizedKind === 'image') {
    return copyImagePreview(url);
  }

  const previewBody = await resolvePreviewBodyForCopy({
    kind,
    url,
    fallbackText,
  });
  await copyText(previewBody);
  return 'text';
}

export async function resolvePreviewBodyForCopy({
  kind,
  url,
  fallbackText,
}: {
  kind: string | null | undefined;
  url: string;
  fallbackText?: string | null;
}) {
  const normalizedFallbackText = String(fallbackText ?? '');

  if (!canCopyPreviewBody(kind)) {
    throw new Error('이 미리보기는 본문 텍스트를 복사할 수 없습니다.');
  }

  try {
    const response = await fetch(url, {
      cache: 'no-store',
    });

    if (!response.ok) {
      throw new Error(`preview 본문을 가져오지 못했습니다. (${response.status})`);
    }

    const bodyText = await response.text();

    if (bodyText.trim()) {
      return bodyText;
    }
  } catch (error) {
    if (!normalizedFallbackText.trim()) {
      throw error;
    }
  }

  if (normalizedFallbackText.trim()) {
    return normalizedFallbackText;
  }

  throw new Error('복사할 preview 본문이 없습니다.');
}

function resolveChatApiBaseUrl() {
  if (import.meta.env.VITE_WORK_SERVER_URL) {
    return import.meta.env.VITE_WORK_SERVER_URL;
  }

  return '/api';
}

async function requestChatApi<T>(
  path: string,
  init?: RequestInit,
  options?: {
    allowUnauthenticated?: boolean;
    signal?: AbortSignal;
    timeoutMs?: number;
    sharePin?: string | null;
  },
): Promise<T> {
  const allowUnauthenticated = options?.allowUnauthenticated === true;
  const headers = appendClientIdHeader(init?.headers);
  const accessToken = getRegisteredAccessToken();
  const method = init?.method?.toUpperCase() ?? 'GET';
  const controller = new AbortController();
  const externalAbortHandler = () => controller.abort();
  const timeoutMs = Number.isFinite(options?.timeoutMs) ? Math.max(1000, Number(options?.timeoutMs)) : 8000;
  const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);

  if (options?.signal) {
    if (options.signal.aborted) {
      window.clearTimeout(timeoutId);
      throw new DOMException('Aborted', 'AbortError');
    }
    options.signal.addEventListener('abort', externalAbortHandler, { once: true });
  }

  if (!allowUnauthenticated && !hasRegisteredAccessTokenAccess()) {
    window.clearTimeout(timeoutId);
    throw new Error('등록된 접근 토큰이 없어 채팅 요청을 보낼 수 없습니다.');
  }

  if (accessToken && !headers.has('X-Access-Token')) {
    headers.set('X-Access-Token', accessToken);
  }

  if (allowUnauthenticated && !headers.has('X-Chat-Share-Pin')) {
    const explicitSharePin = normalizeRequiredText(options?.sharePin);
    const storedSharePin = explicitSharePin ? '' : getStoredChatShareAccessPin(extractChatShareTokenFromPath(path));
    const resolvedSharePin = explicitSharePin || storedSharePin;
    if (resolvedSharePin) {
      headers.set('X-Chat-Share-Pin', resolvedSharePin);
    }
  }

  if (init?.body != null && !headers.has('Content-Type')) {
    headers.set('Content-Type', 'application/json');
  }

  if (method === 'GET') {
    if (!headers.has('Cache-Control')) {
      headers.set('Cache-Control', 'no-store, no-cache, max-age=0');
    }

    if (!headers.has('Pragma')) {
      headers.set('Pragma', 'no-cache');
    }
  }

  let response: Response;

  try {
    response = await fetch(`${resolveChatApiBaseUrl()}/chat${path}`, {
      ...init,
      headers,
      signal: controller.signal,
      cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
    });
  } catch (error) {
    window.clearTimeout(timeoutId);
    if (options?.signal) {
      options.signal.removeEventListener('abort', externalAbortHandler);
    }

    if (error instanceof DOMException && error.name === 'AbortError') {
      if (options?.signal?.aborted) {
        throw error;
      }
      throw new Error('채팅 서버 응답이 지연됩니다.');
    }

    throw new Error('채팅 서버 연결에 실패했습니다.');
  }

  window.clearTimeout(timeoutId);
  if (options?.signal) {
    options.signal.removeEventListener('abort', externalAbortHandler);
  }

  if (!response.ok) {
    const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
    const text = await response.text();

    if (response.status === 413) {
      throw new ChatApiError(
        '첨부 업로드 크기가 현재 허용 한도를 초과했습니다. 300MB 이하 파일로 다시 시도해 주세요.',
        response.status,
      );
    }

    if (contentType.includes('text/html') && text.trim().startsWith('<')) {
      throw new ChatApiError('채팅 API가 HTML 오류 페이지를 반환했습니다. 프록시 업로드 한도를 확인해 주세요.', response.status);
    }

    if (text.trim()) {
      try {
        const payload = JSON.parse(text) as { message?: string; code?: string };
        const normalizedMessage = String(payload.message ?? '').trim();

        if (normalizedMessage) {
          throw new ChatApiError(
            normalizedMessage === 'fetch failed' ? '채팅 서버 연결에 실패했습니다.' : normalizedMessage,
            response.status,
            typeof payload.code === 'string' ? payload.code : null,
          );
        }
      } catch (error) {
        if (error instanceof Error && error.message) {
          throw error;
        }
      }
    }

    throw new ChatApiError('채팅 API 요청에 실패했습니다.', response.status);
  }

  const text = await response.text();

  if (!text.trim()) {
    throw new Error('채팅 서버 응답이 비어 있습니다.');
  }

  try {
    return JSON.parse(text) as T;
  } catch {
    throw new Error('채팅 서버 응답을 해석하지 못했습니다.');
  }
}

function encodeChatAttachmentHeaderValue(value: string) {
  return encodeURIComponent(value);
}

const FALLBACK_UPLOAD_MIME_BY_EXTENSION: Record<string, string> = {
  zip: 'application/zip',
  heic: 'image/heic',
  heif: 'image/heif',
  jpg: 'image/jpeg',
  jpeg: 'image/jpeg',
  png: 'image/png',
  webp: 'image/webp',
  gif: 'image/gif',
  pdf: 'application/pdf',
};

const FALLBACK_UPLOAD_EXTENSION_BY_MIME: Record<string, string> = {
  'application/octet-stream': 'bin',
  'application/pdf': 'pdf',
  'application/zip': 'zip',
  'image/bmp': 'bmp',
  'image/gif': 'gif',
  'image/heic': 'heic',
  'image/heif': 'heif',
  'image/jpeg': 'jpg',
  'image/png': 'png',
  'image/tiff': 'tiff',
  'image/webp': 'webp',
};

function resolveUploadMimeType(file: File) {
  const normalizedName = String(file.name ?? '').trim().toLowerCase();
  const extension = normalizedName.includes('.') ? normalizedName.split('.').pop()?.trim() ?? '' : '';
  const normalizedType = String(file.type ?? '').trim().toLowerCase();

  if (normalizedType && normalizedType !== 'application/octet-stream') {
    return normalizedType;
  }

  if (extension && FALLBACK_UPLOAD_MIME_BY_EXTENSION[extension]) {
    return FALLBACK_UPLOAD_MIME_BY_EXTENSION[extension];
  }

  return normalizedType || 'application/octet-stream';
}

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

  if (normalizedName) {
    return normalizedName;
  }

  const resolvedMimeType = resolveUploadMimeType(file);
  const extension = FALLBACK_UPLOAD_EXTENSION_BY_MIME[resolvedMimeType] ?? 'bin';
  const baseName = resolvedMimeType.startsWith('image/') ? 'pasted-image' : 'attachment';
  return `${baseName}-${Date.now().toString(36)}.${extension}`;
}

export async function fetchChatConversations() {
  if (chatConversationListRequestPromise) {
    return chatConversationListRequestPromise;
  }

  const clientId = getOrCreateClientId();
  chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>(
    '/conversations?limit=200',
  )
    .then((response) => {
      return sortChatConversationSummaries(
        response.items.map((item) => ({
          ...item,
          hasUnreadResponse: resolveStoredConversationUnreadState(item),
          hasPendingAttention: item.hasPendingAttention === true,
          notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
        })),
      );
    })
    .finally(() => {
      chatConversationListRequestPromise = null;
    });

  return chatConversationListRequestPromise;
}

export async function fetchChatConversationDetail(
  sessionId: string,
  options: {
    limit?: number;
    beforeMessageId?: number | null;
  } = {},
) {
  const clientId = getOrCreateClientId();
  const query = new URLSearchParams();

  if (options.limit != null) {
    query.set('limit', String(options.limit));
  }

  if (Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0) {
    query.set('beforeMessageId', String(options.beforeMessageId));
  }

  const response = await requestChatApi<ChatConversationDetailResponse>(
    `/conversations/${encodeURIComponent(sessionId)}${query.toString() ? `?${query.toString()}` : ''}`,
  );
  const normalizedMessages = Array.isArray(response.messages)
    ? response.messages.map((message, index) => normalizeChatMessage(message, index))
    : [];
  const normalizedRequests = enrichFailedRequestsWithActivityLogs(
    response.requests.map((item) => normalizeChatConversationRequest(item)),
    response.activityLogs,
  );
  const visibleRequestIds = new Set(
    normalizedMessages
      .map((message) => message.clientRequestId?.trim() ?? '')
      .filter(Boolean),
  );
  const hydratedMessages = hydrateActivityLogMessages(
    replaceGenericFailureMessages(normalizedMessages, normalizedRequests, response.activityLogs),
    response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
  ).filter(
    (message) =>
      message.author !== 'system' ||
      isActivityLogMessage(message) ||
      isMissingRequestMessage(message) ||
      isExecutionFailureMessage(message),
  );
  const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs);

  return {
    ...response,
    messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages),
    item: {
      ...response.item,
      lastMessagePreview: resolveConversationFailurePreview(
        response.item.lastMessagePreview,
        normalizedRequests,
        response.activityLogs,
      ),
      notifyOffline: resolveSyncedChatOfflineNotificationSetting(
        response.item.sessionId,
        response.item.notifyOffline,
        clientId,
      ),
    },
    requests: normalizedRequests,
  };
}

export async function fetchChatShareConversationDetail(
  token: string,
  options: {
    sessionId: string;
    limit?: number;
    beforeMessageId?: number | null;
    sharePin?: string | null;
  },
) {
  const query = new URLSearchParams();

  if (options.limit != null) {
    query.set('limit', String(options.limit));
  }

  if (Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0) {
    query.set('beforeMessageId', String(options.beforeMessageId));
  }

  const response = await requestChatApi<ChatConversationDetailResponse>(
    `/shares/${encodeURIComponent(token)}/conversations/${encodeURIComponent(options.sessionId)}${query.toString() ? `?${query.toString()}` : ''}`,
    undefined,
    {
      allowUnauthenticated: true,
      sharePin: options.sharePin,
      timeoutMs: 20000,
    },
  );
  const normalizedMessages = Array.isArray(response.messages)
    ? response.messages.map((message, index) => normalizeChatMessage(message, index))
    : [];
  const normalizedRequests = enrichFailedRequestsWithActivityLogs(
    response.requests.map((item) => normalizeChatConversationRequest(item)),
    response.activityLogs,
  );
  const visibleRequestIds = new Set(
    normalizedMessages
      .map((message) => message.clientRequestId?.trim() ?? '')
      .filter(Boolean),
  );
  const hydratedMessages = hydrateActivityLogMessages(
    replaceGenericFailureMessages(normalizedMessages, normalizedRequests, response.activityLogs),
    response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
  ).filter(
    (message) =>
      message.author !== 'system' ||
      isActivityLogMessage(message) ||
      isMissingRequestMessage(message) ||
      isExecutionFailureMessage(message),
  );
  const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs);

  return {
    ...response,
    messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages),
    item: {
      ...response.item,
      lastMessagePreview: resolveConversationFailurePreview(
        response.item.lastMessagePreview,
        normalizedRequests,
        response.activityLogs,
      ),
      notifyOffline: resolveSyncedChatOfflineNotificationSetting(
        response.item.sessionId,
        response.item.notifyOffline,
        getOrCreateClientId(),
      ),
    },
    requests: normalizedRequests,
  };
}

export async function fetchChatRuntimeSnapshot() {
  const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>('/runtime');
  return response.item;
}

export async function fetchChatShareRuntimeSnapshot(
  token: string,
  options?: {
    sessionId?: string | null;
    sharePin?: string | null;
  },
) {
  const query = new URLSearchParams();
  const normalizedSessionId = options?.sessionId?.trim() || '';

  if (normalizedSessionId) {
    query.set('sessionId', normalizedSessionId);
  }

  const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>(
    `/shares/${encodeURIComponent(token)}/runtime${query.size > 0 ? `?${query.toString()}` : ''}`,
    undefined,
    {
      allowUnauthenticated: true,
      sharePin: options?.sharePin,
      timeoutMs: 20000,
    },
  );
  return response.item;
}

export async function cancelChatShareRuntimeRequest(
  token: string,
  payload: {
    requestId: string;
    sessionId?: string | null;
  },
) {
  const response = await requestChatApi<{ ok: boolean; action: 'cancelled' | 'removed' }>(
    `/shares/${encodeURIComponent(token)}/runtime-requests/${encodeURIComponent(payload.requestId)}/cancel`,
    {
      method: 'POST',
      body: JSON.stringify({
        sessionId: payload.sessionId?.trim() || undefined,
      }),
    },
    {
      allowUnauthenticated: true,
    },
  );
  return response.action;
}

export async function fetchChatSourceChanges(limit = 300) {
  const query = new URLSearchParams();
  query.set('limit', String(Math.max(1, Math.min(500, Math.round(limit)))));
  const response = await requestChatApi<ChatSourceChangeSnapshotListResponse>(`/source-changes?${query.toString()}`);
  const normalizedItems = Array.isArray(response.items)
    ? response.items.flatMap((item) => {
        try {
          return [normalizeChatSourceChangeSnapshot(item)];
        } catch {
          return [];
        }
      })
    : [];

  if (normalizedItems.length === 0 && Array.isArray(response.items) && response.items.length > 0) {
    throw new Error('Codex Live 변경 이력을 읽지 못했습니다.');
  }

  return normalizedItems;
}

export async function fetchChatRuntimeJobDetail(requestId: string) {
  const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeJobDetail }>(
    `/runtime/jobs/${encodeURIComponent(requestId)}`,
  );
  return response.item;
}

export async function cancelChatRuntimeJob(requestId: string) {
  const response = await requestChatApi<{ ok: boolean; cancelled: boolean }>(
    `/runtime/jobs/${encodeURIComponent(requestId)}/cancel`,
    {
      method: 'POST',
    },
  );
  return response.cancelled;
}

export async function removeChatRuntimeJob(requestId: string) {
  const response = await requestChatApi<{ ok: boolean; removed: boolean }>(
    `/runtime/jobs/${encodeURIComponent(requestId)}/remove`,
    {
      method: 'POST',
    },
  );
  return response.removed;
}

export async function rollbackChatRuntimeJob(requestId: string, sessionId?: string | null) {
  const response = await requestChatApi<{ ok: boolean; rolledBack: boolean }>(
    `/runtime/jobs/${encodeURIComponent(requestId)}/rollback`,
    {
      method: 'POST',
      body: JSON.stringify({
        sessionId: sessionId?.trim() || undefined,
      }),
    },
  );
  return response.rolledBack;
}

function normalizeChatSourceChangeSnapshot(item: ChatSourceChangeSnapshot): ChatSourceChangeSnapshot {
  return {
    ...item,
    clientId: item.clientId?.trim() || null,
    conversationTitle: item.conversationTitle.trim() || '새 대화',
    chatTypeId: item.chatTypeId?.trim() || null,
    chatTypeLabel: item.chatTypeLabel.trim(),
    requestId: item.requestId.trim(),
    requestTitle: item.requestTitle.trim() || item.requestId.trim(),
    questionText: item.questionText,
    answerText: item.answerText,
    status: item.status,
    sourceChangedAt: item.sourceChangedAt,
    updatedAt: item.updatedAt,
    featureTags: item.featureTags.map((value) => value.trim()).filter(Boolean),
    changedFiles: item.changedFiles.map((value) => value.trim()).filter(Boolean),
    currentSourceFiles: item.currentSourceFiles.map((value) => value.trim()).filter(Boolean),
    diffBlocks: item.diffBlocks.map((value) => value.trim()).filter(Boolean),
    hasSourceChanges: item.hasSourceChanges === true,
    reviewStatus: item.reviewStatus === 'reviewed' ? 'reviewed' : 'not-reviewed',
    sourceChangeKind: item.sourceChangeKind === 'verification-group' ? 'verification-group' : 'request',
    sourceEntryIds: (Array.isArray(item.sourceEntryIds) ? item.sourceEntryIds : []).map((value) => String(value).trim()).filter(Boolean),
    conversationDeletedAt: item.conversationDeletedAt?.trim() || null,
  };
}

async function uploadChatAttachmentBinary(
  path: string,
  file: File,
  args: {
    sessionId: string;
    fileName: string;
    mimeType: string;
    allowUnauthenticated?: boolean;
    sharePin?: string | null;
  },
) {
  const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>(
    path,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/octet-stream',
        'X-Chat-Attachment-Session-Id': encodeChatAttachmentHeaderValue(args.sessionId),
        'X-Chat-Attachment-File-Name': encodeChatAttachmentHeaderValue(args.fileName),
        'X-Chat-Attachment-Mime-Type': encodeChatAttachmentHeaderValue(args.mimeType),
      },
      body: file,
    },
    {
      allowUnauthenticated: args.allowUnauthenticated,
      sharePin: args.sharePin,
    },
  );

  return response.item;
}

export async function uploadChatComposerFile(sessionId: string, file: File) {
  const normalizedSessionId = sessionId.trim();
  const resolvedMimeType = resolveUploadMimeType(file);
  const resolvedFileName = resolveUploadFileName(file);
  const reportUploadFailure = async (stage: string, error: Error) => {
    await reportClientError({
      errorType: 'chat:composer-upload',
      errorName: error.name,
      errorMessage: error.message,
      requestMethod: 'POST',
      requestPath: '/api/chat/attachments',
      context: {
        stage,
        sessionId: normalizedSessionId || null,
        fileName: resolvedFileName,
        fileSize: file.size,
        fileType: file.type || null,
        resolvedMimeType,
      },
    });
  };

  if (!normalizedSessionId) {
    const uploadError = new Error('채팅 세션이 준비되지 않았습니다.');
    await reportUploadFailure('validate-session', uploadError);
    throw uploadError;
  }

  if (file.size <= 0) {
    const uploadError = new Error('업로드할 파일 내용을 찾지 못했습니다.');
    await reportUploadFailure('validate-file', uploadError);
    throw uploadError;
  }

  if (file.size > CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT) {
    const uploadError = new Error(`첨부 파일은 300MB 이하만 업로드할 수 있습니다. (${resolvedFileName})`);
    await reportUploadFailure('validate-file', uploadError);
    throw uploadError;
  }

  try {
    return await uploadChatAttachmentBinary('/attachments', file, {
      sessionId: normalizedSessionId,
      fileName: resolvedFileName,
      mimeType: resolvedMimeType,
    });
  } catch (error) {
    const uploadError =
      error instanceof Error && error.message.trim()
        ? error
        : new Error(`${resolvedFileName} 업로드에 실패했습니다.`);
    await reportUploadFailure('upload-request', uploadError);
    throw uploadError;
  }
}

export async function uploadChatShareComposerFile(token: string, sessionId: string, file: File) {
  const normalizedToken = token.trim();
  const normalizedSessionId = sessionId.trim();
  const resolvedMimeType = resolveUploadMimeType(file);
  const resolvedFileName = resolveUploadFileName(file);
  const reportUploadFailure = async (stage: string, error: Error) => {
    await reportClientError({
      errorType: 'chat:composer-upload',
      errorName: error.name,
      errorMessage: error.message,
      requestMethod: 'POST',
      requestPath: `/api/chat/shares/${normalizedToken ? ':token' : ''}/attachments`,
      context: {
        stage,
        shareTokenPresent: Boolean(normalizedToken),
        sessionId: normalizedSessionId || null,
        fileName: resolvedFileName,
        fileSize: file.size,
        fileType: file.type || null,
        resolvedMimeType,
      },
    });
  };

  if (!normalizedToken) {
    const uploadError = new Error('공유 링크 토큰이 준비되지 않았습니다.');
    await reportUploadFailure('validate-token', uploadError);
    throw uploadError;
  }

  if (!normalizedSessionId) {
    const uploadError = new Error('공유 채팅 세션이 준비되지 않았습니다.');
    await reportUploadFailure('validate-session', uploadError);
    throw uploadError;
  }

  if (file.size <= 0) {
    const uploadError = new Error('업로드할 파일 내용을 찾지 못했습니다.');
    await reportUploadFailure('validate-file', uploadError);
    throw uploadError;
  }

  if (file.size > CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT) {
    const uploadError = new Error(`첨부 파일은 300MB 이하만 업로드할 수 있습니다. (${resolvedFileName})`);
    await reportUploadFailure('validate-file', uploadError);
    throw uploadError;
  }

  try {
    return await uploadChatAttachmentBinary(
      `/shares/${encodeURIComponent(normalizedToken)}/attachments`,
      file,
      {
        sessionId: normalizedSessionId,
        fileName: resolvedFileName,
        mimeType: resolvedMimeType,
        allowUnauthenticated: true,
      },
    );
  } catch (error) {
    const uploadError =
      error instanceof Error && error.message.trim()
        ? error
        : new Error(`${resolvedFileName} 업로드에 실패했습니다.`);
    await reportUploadFailure('upload-request', uploadError);
    throw uploadError;
  }
}

export async function createChatConversationRoom(args: {
  sessionId: string;
  title?: string;
  draftText?: string | null;
  requestBadgeLabel?: string | null;
  codexModel?: string | null;
  chatTypeId?: string | null;
  lastChatTypeId?: string | null;
  generalSectionName?: string | null;
  contextLabel?: string;
  contextDescription?: string;
  notifyOffline?: boolean;
  roomScope?: Record<string, unknown> | null;
}) {
  const clientId = getOrCreateClientId();
  const notifyOffline = args.notifyOffline ?? true;
  const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>('/conversations', {
    method: 'POST',
    body: JSON.stringify({
      sessionId: args.sessionId,
      title: args.title ?? '새 대화',
      draftText: args.draftText ?? '',
      requestBadgeLabel: args.requestBadgeLabel ?? null,
      codexModel: args.codexModel ?? null,
      chatTypeId: args.chatTypeId ?? null,
      lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null,
      generalSectionName: args.generalSectionName ?? null,
      contextLabel: args.contextLabel ?? null,
      contextDescription: args.contextDescription ?? null,
      roomScope: args.roomScope ?? null,
      notifyOffline,
      clientId,
    }),
  });

  invalidateChatConversationListCache();

  return {
    ...response.item,
    notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
  };
}

export async function updateChatConversationRoom(
  sessionId: string,
  payload: {
    title?: string | null;
    draftText?: string | null;
    requestBadgeLabel?: string | null;
    codexModel?: string | null;
    chatTypeId?: string | null;
    lastChatTypeId?: string | null;
    generalSectionName?: string | null;
    contextLabel?: string | null;
    contextDescription?: string | null;
    notifyOffline?: boolean;
    roomScope?: Record<string, unknown> | null;
  },
) {
  const clientId = getOrCreateClientId();
  const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>(
    `/conversations/${encodeURIComponent(sessionId)}`,
    {
      method: 'PATCH',
      body: JSON.stringify(payload),
    },
  );

  invalidateChatConversationListCache();

  return {
    ...response.item,
    notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
  };
}

export async function markChatConversationResponsesRead(sessionId: string) {
  const response = await requestChatApi<{
    ok: boolean;
    sessionId: string;
    lastReadResponseMessageId: number | null;
  }>(`/conversations/${encodeURIComponent(sessionId)}/read`, {
    method: 'POST',
  });

  return response;
}

export async function deleteChatConversationRoom(sessionId: string) {
  const response = await requestChatApi<{ ok: boolean; deleted: boolean; sessionId: string }>(
    `/conversations/${encodeURIComponent(sessionId)}`,
    {
      method: 'DELETE',
    },
  );

  invalidateChatConversationListCache();

  return response;
}

export async function clearChatConversationRoom(sessionId: string) {
  const clientId = getOrCreateClientId();
  const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>(
    `/conversations/${encodeURIComponent(sessionId)}/clear`,
    {
      method: 'POST',
    },
  );

  invalidateChatConversationListCache();

  return {
    ...response.item,
    notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
  };
}

export async function clearChatShareConversationRoom(token: string, sessionId?: string | null) {
  const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>(
    `/shares/${encodeURIComponent(token)}/clear`,
    {
      method: 'POST',
      body: JSON.stringify({
        sessionId: sessionId?.trim() || undefined,
      }),
    },
    {
      allowUnauthenticated: true,
    },
  );

  return response.item;
}

export async function createChatShareRoom(
  token: string,
  payload: {
    chatTypeId: string;
    chatTypeLabel: string;
    title: string;
    requestBadgeLabel?: string | null;
    seedMessage: string;
    linkedSessionId?: string | null;
    linkedRequestId?: string | null;
    linkedTitle?: string | null;
    linkedRequestPreview?: string | null;
    linkedChatTypeLabel?: string | null;
  },
) {
  const response = await requestChatApi<{ ok: boolean; room: ChatShareRoomSummary }>(
    `/shares/${encodeURIComponent(token)}/rooms`,
    {
      method: 'POST',
      body: JSON.stringify(payload),
    },
    {
      allowUnauthenticated: true,
    },
  );

  return {
    sessionId: normalizeRequiredText(response.room.sessionId),
    requestId: normalizeRequiredText(response.room.requestId),
    isDefault: response.room.isDefault === true,
    sortOrder: Number.isFinite(response.room.sortOrder) ? Number(response.room.sortOrder) : 0,
    title: normalizeRequiredText(response.room.title) || '공유 채팅방',
    requestBadgeLabel: normalizeOptionalText(response.room.requestBadgeLabel),
    chatTypeId: normalizeOptionalText(response.room.chatTypeId),
    lastChatTypeId: normalizeOptionalText(response.room.lastChatTypeId),
    contextLabel: normalizeOptionalText(response.room.contextLabel),
    contextDescription: normalizeOptionalText(response.room.contextDescription),
    notifyOffline: response.room.notifyOffline === true,
    linkContext:
      response.room.linkContext?.kind === 'linked-session'
        ? {
            kind: 'linked-session',
            sourceSessionId: normalizeRequiredText(response.room.linkContext.sourceSessionId),
            sourceRequestId: normalizeRequiredText(response.room.linkContext.sourceRequestId),
            sourceTitle: normalizeOptionalText(response.room.linkContext.sourceTitle),
            sourceRequestPreview: normalizeOptionalText(response.room.linkContext.sourceRequestPreview),
            sourceChatTypeLabel: normalizeOptionalText(response.room.linkContext.sourceChatTypeLabel),
            linkedAt: normalizeOptionalText(response.room.linkContext.linkedAt),
          }
        : null,
    createdAt: normalizeOptionalText(response.room.createdAt),
    updatedAt: normalizeOptionalText(response.room.updatedAt),
  } satisfies ChatShareRoomSummary;
}

export async function deleteChatShareRoom(token: string, sessionId: string) {
  const response = await requestChatApi<{
    ok: boolean;
    deleted: boolean;
    deletedSessionId: string;
    nextRoomSessionId?: string | null;
  }>(
    `/shares/${encodeURIComponent(token)}/rooms/${encodeURIComponent(sessionId)}`,
    {
      method: 'DELETE',
    },
    {
      allowUnauthenticated: true,
    },
  );

  return {
    deleted: response.deleted === true,
    deletedSessionId: normalizeRequiredText(response.deletedSessionId),
    nextRoomSessionId: normalizeOptionalText(response.nextRoomSessionId),
  };
}

export async function deleteChatConversationRequest(sessionId: string, requestId: string) {
  const response = await requestChatApi<{ ok: boolean; deleted: boolean; sessionId: string; requestId: string }>(
    `/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}`,
    {
      method: 'DELETE',
    },
  );

  return response;
}

export async function completeChatConversationRequestManualBadge(
  sessionId: string,
  requestId: string,
  type: 'prompt' | 'verification',
) {
  const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest }>(
    `/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}/manual-completion`,
    {
      method: 'POST',
      body: JSON.stringify({ type }),
    },
  );

  return normalizeChatConversationRequest(response.item);
}

export async function persistChatPromptSelection(
  sessionId: string,
  payload: {
    parentRequestId: string;
    sessionId?: string | null;
    promptIndex: number;
    promptTitle: string;
    promptSignature: string;
    sourceMessageId: number;
    selectedValues: string[];
    freeText?: string | null;
    stepSelections?: Array<{
      stepKey: string;
      stepTitle: string;
      selectedValues: string[];
      freeText: string;
      skipped?: boolean;
    }>;
    summaryText?: string | null;
  },
) {
  const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest; message: ChatMessage }>(
    `/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(payload.parentRequestId)}/prompt-selection`,
    {
      method: 'POST',
      body: JSON.stringify(payload),
    },
    {
      allowUnauthenticated: true,
    },
  );

  return {
    item: normalizeChatConversationRequest(response.item),
    message: {
      ...normalizeChatMessage(response.message),
    },
  };
}

export async function submitChatPromptSelection(
  sessionId: string,
  payload: {
    parentRequestId: string;
    promptIndex: number;
    promptTitle: string;
    promptSignature: string;
    sourceMessageId: number;
    selectedValues: string[];
    freeText?: string | null;
    stepSelections?: Array<{
      stepKey: string;
      stepTitle: string;
      selectedValues: string[];
      freeText: string;
      skipped?: boolean;
    }>;
    summaryText?: string | null;
    attachments?: ChatComposerAttachment[];
    followupText: string;
    mode?: 'queue' | 'direct';
    contextRef?: ChatPromptContextRef | null;
  },
) {
  const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest; message: ChatMessage; queuedRequestId: string }>(
    `/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(payload.parentRequestId)}/prompt-selection/submit`,
    {
      method: 'POST',
      body: JSON.stringify(payload),
    },
  );

  return {
    queuedRequestId: response.queuedRequestId,
    item: normalizeChatConversationRequest(response.item),
    message: {
      ...normalizeChatMessage(response.message),
    },
  };
}

export type ChatShareKind = 'request-bundle' | 'inquiry-message' | 'prompt';

export type ChatShareRoomSummary = {
  sessionId: string;
  requestId: string;
  isDefault: boolean;
  sortOrder: number;
  title: string;
  requestBadgeLabel?: string | null;
  chatTypeId?: string | null;
  lastChatTypeId?: string | null;
  contextLabel?: string | null;
  contextDescription?: string | null;
  notifyOffline?: boolean;
  linkContext?: ChatShareRoomLinkContext | null;
  createdAt?: string | null;
  updatedAt?: string | null;
};

export type ChatShareSnapshot = {
  detailLevel?: 'full' | 'initial';
  share: {
    kind: ChatShareKind;
    sessionId: string;
    requestId: string;
    sharePath: string;
    createdAt?: string | null;
    expiresAt?: string | null;
    tokenSetting: {
      id: string;
      name: string;
      defaultExpiresInMinutes: number;
      maxTokensPer30Days: number;
      maxTokensPer7Days: number;
      maxTokensPer5Hours: number;
      oneTimeTokenLimit: number;
      allowedAppIds: string[];
    } | null;
    managedResourceTokenId?: string | null;
    permissions?: Array<'view' | 'download' | 'comment' | 'upload' | 'manage'>;
    hasAccessPin?: boolean;
    accessPinPromptTtlMinutes?: number | null;
    accessPinSessionExpiresAt?: string | null;
    canSendMessage?: boolean;
    blockedReason?: string | null;
  };
  conversation: {
    sessionId: string;
    title: string;
    requestBadgeLabel?: string | null;
    chatTypeId?: string | null;
    lastChatTypeId?: string | null;
    contextLabel?: string | null;
    contextDescription?: string | null;
    notifyOffline?: boolean;
  };
  rootRequestId: string;
  targetRequest: ChatConversationRequest;
  requests: ChatConversationRequest[];
  messages: ChatMessage[];
  activityLogs: ChatConversationActivityLog[];
  rooms: ChatShareRoomSummary[];
  activeSessionId?: string | null;
  roomRequestCounts?: {
    processingCount: number;
    unansweredCount: number;
  };
  oldestLoadedMessageId?: number | null;
  hasOlderMessages?: boolean;
  promptTarget?: {
    sourceMessageId: number;
    promptIndex: number;
    prompt: Extract<ChatMessagePart, { type: 'prompt' }>;
  } | null;
  refreshedAt: string;
};

export type ManagedChatShareRoomDraft = {
  tokenSettingId: string;
  chatTypeId: string;
  chatTypeLabel: string;
  name: string;
  requestBadgeLabel?: string | null;
  seedMessage: string;
  allowManageAccess?: boolean;
};

export type ManagedChatShareRoom = {
  sessionId: string;
  requestId: string;
  token: string;
  sharePath: string;
  managedResourceTokenId?: string | null;
  name: string;
  requestBadgeLabel?: string | null;
  seedMessage: string;
  permissions: Array<'view' | 'download' | 'comment' | 'upload' | 'manage'>;
  hasAccessPin?: boolean;
  tokenSetting: NonNullable<ChatShareSnapshot['share']['tokenSetting']>;
};

export async function createChatShareLink(payload: {
  kind: ChatShareKind;
  sessionId: string;
  requestId: string;
  name: string;
  sourceMessageId?: number;
  promptIndex?: number;
  promptSignature?: string;
  tokenSettingId: string;
}) {
  const response = await requestChatApi<{
    ok: boolean;
    token: string;
    sharePath: string;
    name: string;
    tokenSetting: NonNullable<ChatShareSnapshot['share']['tokenSetting']>;
    managedResourceTokenId?: string | null;
  }>(
    '/shares',
    {
      method: 'POST',
      body: JSON.stringify({
        ...payload,
        title: payload.name,
        tokenName: payload.name,
      }),
    },
  );

  return response;
}

export async function createManagedChatShareRoom(payload: ManagedChatShareRoomDraft) {
  const response = await requestChatApi<{
    ok: boolean;
    sessionId: string;
    requestId: string;
    token: string;
    sharePath: string;
    managedResourceTokenId?: string | null;
    name: string;
    requestBadgeLabel?: string | null;
    seedMessage: string;
    permissions: ManagedChatShareRoom['permissions'];
    hasAccessPin?: boolean;
    tokenSetting: NonNullable<ChatShareSnapshot['share']['tokenSetting']>;
  }>(
    '/shares/rooms',
    {
      method: 'POST',
      body: JSON.stringify({
        ...payload,
        title: payload.name,
        tokenName: payload.name,
      }),
    },
  );

  return {
    ...response,
    managedResourceTokenId: normalizeOptionalText(response.managedResourceTokenId),
    requestBadgeLabel: normalizeOptionalText(response.requestBadgeLabel),
    permissions: Array.isArray(response.permissions)
      ? response.permissions.filter(
          (item): item is 'view' | 'download' | 'comment' | 'upload' | 'manage' =>
            item === 'view' || item === 'download' || item === 'comment' || item === 'upload' || item === 'manage',
          )
        : [],
    hasAccessPin: response.hasAccessPin === true,
    tokenSetting: {
      id: normalizeRequiredText(response.tokenSetting.id),
      name: normalizeRequiredText(response.tokenSetting.name),
      defaultExpiresInMinutes: Number.isFinite(response.tokenSetting.defaultExpiresInMinutes)
        ? Number(response.tokenSetting.defaultExpiresInMinutes)
        : 0,
      maxTokensPer30Days: Number.isFinite(response.tokenSetting.maxTokensPer30Days)
        ? Number(response.tokenSetting.maxTokensPer30Days)
        : 0,
      maxTokensPer7Days: Number.isFinite(response.tokenSetting.maxTokensPer7Days)
        ? Number(response.tokenSetting.maxTokensPer7Days)
        : 0,
      maxTokensPer5Hours: Number.isFinite(response.tokenSetting.maxTokensPer5Hours)
        ? Number(response.tokenSetting.maxTokensPer5Hours)
        : 0,
      oneTimeTokenLimit: Number.isFinite(response.tokenSetting.oneTimeTokenLimit)
        ? Number(response.tokenSetting.oneTimeTokenLimit)
        : 0,
      allowedAppIds: Array.isArray(response.tokenSetting.allowedAppIds)
        ? response.tokenSetting.allowedAppIds.map((item) => normalizeRequiredText(item)).filter(Boolean)
        : [],
    },
  } satisfies ManagedChatShareRoom;
}

export async function saveChatShareRoomSettings(
  token: string,
  input: {
    sessionId?: string | null;
    accessPin?: string | null;
    accessPinPromptTtlMinutes?: number | null;
    chatTypeId?: string | null;
    chatTypeLabel?: string | null;
    title?: string | null;
    notifyOffline?: boolean | null;
  },
) {
  const response = await requestChatApi<{
    ok: boolean;
    hasAccessPin: boolean;
    accessPinPromptTtlMinutes?: number | null;
    conversation?: {
      sessionId?: string | null;
      title?: string | null;
      requestBadgeLabel?: string | null;
      chatTypeId?: string | null;
      lastChatTypeId?: string | null;
      contextLabel?: string | null;
      contextDescription?: string | null;
      notifyOffline?: boolean | null;
    } | null;
  }>(
    `/shares/${encodeURIComponent(token)}/room-settings`,
    {
      method: 'POST',
      body: JSON.stringify({
        sessionId: input.sessionId,
        accessPin: input.accessPin,
        accessPinPromptTtlMinutes: input.accessPinPromptTtlMinutes,
        chatTypeId: input.chatTypeId,
        chatTypeLabel: input.chatTypeLabel,
        title: input.title,
        notifyOffline: input.notifyOffline,
      }),
    },
    {
      allowUnauthenticated: true,
    },
  );

  return {
    hasAccessPin: response.hasAccessPin === true,
    accessPinPromptTtlMinutes:
      Number.isFinite(response.accessPinPromptTtlMinutes) && Number(response.accessPinPromptTtlMinutes) >= 0
        ? Math.max(0, Number(response.accessPinPromptTtlMinutes))
        : 0,
    conversation: response.conversation
      ? {
          sessionId: normalizeOptionalText(response.conversation.sessionId),
          title: normalizeOptionalText(response.conversation.title),
          requestBadgeLabel: normalizeOptionalText(response.conversation.requestBadgeLabel),
          chatTypeId: normalizeOptionalText(response.conversation.chatTypeId),
          lastChatTypeId: normalizeOptionalText(response.conversation.lastChatTypeId),
          contextLabel: normalizeOptionalText(response.conversation.contextLabel),
          contextDescription: normalizeOptionalText(response.conversation.contextDescription),
          notifyOffline: response.conversation.notifyOffline === true,
        }
      : null,
  };
}

export async function fetchChatShareSnapshot(
  token: string,
  options?: {
    sharePin?: string | null;
    sessionId?: string | null;
    signal?: AbortSignal;
    view?: 'full' | 'initial';
  },
) {
  const query = new URLSearchParams();

  if (options?.sessionId?.trim()) {
    query.set('sessionId', options.sessionId.trim());
  }

  if (options?.view === 'initial') {
    query.set('view', 'initial');
  }

  const response = await requestChatApi<{
    ok: boolean;
    detailLevel?: ChatShareSnapshot['detailLevel'];
    share: ChatShareSnapshot['share'];
    conversation: ChatShareSnapshot['conversation'];
    rootRequestId: string;
    targetRequest: ChatConversationRequest;
    requests: ChatConversationRequest[];
    messages: ChatMessage[];
    activityLogs: ChatConversationActivityLog[];
    rooms?: ChatShareRoomSummary[];
    activeSessionId?: string | null;
    roomRequestCounts?: ChatShareSnapshot['roomRequestCounts'];
    oldestLoadedMessageId?: number | null;
    hasOlderMessages?: boolean;
    promptTarget?: ChatShareSnapshot['promptTarget'];
    refreshedAt: string;
  }>(
    `/shares/${encodeURIComponent(token)}${query.size > 0 ? `?${query.toString()}` : ''}`,
    undefined,
    {
      allowUnauthenticated: true,
      signal: options?.signal,
      sharePin: options?.sharePin,
      timeoutMs: 20000,
    },
  );

  return {
    detailLevel: response.detailLevel === 'initial' ? 'initial' : 'full',
    share: {
      ...response.share,
      createdAt: normalizeOptionalText(response.share?.createdAt),
      expiresAt: normalizeOptionalText(response.share?.expiresAt),
      tokenSetting: response.share?.tokenSetting
        ? {
            id: normalizeRequiredText(response.share.tokenSetting.id),
            name: normalizeRequiredText(response.share.tokenSetting.name),
            defaultExpiresInMinutes: Number.isFinite(response.share.tokenSetting.defaultExpiresInMinutes)
              ? Number(response.share.tokenSetting.defaultExpiresInMinutes)
              : 0,
            maxTokensPer30Days: Number.isFinite(response.share.tokenSetting.maxTokensPer30Days)
              ? Number(response.share.tokenSetting.maxTokensPer30Days)
              : 0,
            maxTokensPer7Days: Number.isFinite(response.share.tokenSetting.maxTokensPer7Days)
              ? Number(response.share.tokenSetting.maxTokensPer7Days)
              : 0,
            maxTokensPer5Hours: Number.isFinite(response.share.tokenSetting.maxTokensPer5Hours)
              ? Number(response.share.tokenSetting.maxTokensPer5Hours)
              : 0,
            oneTimeTokenLimit: Number.isFinite(response.share.tokenSetting.oneTimeTokenLimit)
              ? Number(response.share.tokenSetting.oneTimeTokenLimit)
              : 0,
            allowedAppIds: Array.isArray(response.share.tokenSetting.allowedAppIds)
              ? response.share.tokenSetting.allowedAppIds.map((item) => normalizeRequiredText(item)).filter(Boolean)
              : [],
          }
        : null,
      managedResourceTokenId: normalizeOptionalText(response.share?.managedResourceTokenId),
      hasAccessPin: response.share?.hasAccessPin === true,
      accessPinPromptTtlMinutes:
        Number.isFinite(response.share?.accessPinPromptTtlMinutes) && Number(response.share?.accessPinPromptTtlMinutes) >= 0
          ? Math.max(0, Number(response.share?.accessPinPromptTtlMinutes))
          : 0,
      accessPinSessionExpiresAt: normalizeOptionalText(response.share?.accessPinSessionExpiresAt),
      permissions: Array.isArray(response.share?.permissions)
        ? response.share.permissions.filter(
            (item): item is 'view' | 'download' | 'comment' | 'upload' | 'manage' =>
              item === 'view' || item === 'download' || item === 'comment' || item === 'upload' || item === 'manage',
          )
        : [],
    },
    conversation: {
      ...response.conversation,
      chatTypeId: normalizeOptionalText(response.conversation?.chatTypeId),
      lastChatTypeId: normalizeOptionalText(response.conversation?.lastChatTypeId),
      contextLabel: normalizeOptionalText(response.conversation?.contextLabel),
      contextDescription: normalizeOptionalText(response.conversation?.contextDescription),
      notifyOffline: response.conversation?.notifyOffline === true,
    },
    rootRequestId: response.rootRequestId,
    targetRequest: normalizeChatConversationRequest(response.targetRequest),
    requests: Array.isArray(response.requests) ? response.requests.map((item) => normalizeChatConversationRequest(item)) : [],
    messages: Array.isArray(response.messages)
      ? response.messages.map((message, index) => normalizeChatMessage(message, index))
      : [],
    activityLogs: Array.isArray(response.activityLogs) ? response.activityLogs : [],
    rooms: Array.isArray(response.rooms)
      ? response.rooms.map((item) => ({
          sessionId: normalizeRequiredText(item.sessionId),
          requestId: normalizeRequiredText(item.requestId),
          isDefault: item.isDefault === true,
          sortOrder: Number.isFinite(item.sortOrder) ? Number(item.sortOrder) : 0,
          title: normalizeRequiredText(item.title) || '공유 채팅방',
          requestBadgeLabel: normalizeOptionalText(item.requestBadgeLabel),
          chatTypeId: normalizeOptionalText(item.chatTypeId),
          lastChatTypeId: normalizeOptionalText(item.lastChatTypeId),
          contextLabel: normalizeOptionalText(item.contextLabel),
          contextDescription: normalizeOptionalText(item.contextDescription),
          notifyOffline: item.notifyOffline === true,
          linkContext:
            item.linkContext?.kind === 'linked-session'
              ? {
                  kind: 'linked-session',
                  sourceSessionId: normalizeRequiredText(item.linkContext.sourceSessionId),
                  sourceRequestId: normalizeRequiredText(item.linkContext.sourceRequestId),
                  sourceTitle: normalizeOptionalText(item.linkContext.sourceTitle),
                  sourceRequestPreview: normalizeOptionalText(item.linkContext.sourceRequestPreview),
                  sourceChatTypeLabel: normalizeOptionalText(item.linkContext.sourceChatTypeLabel),
                  linkedAt: normalizeOptionalText(item.linkContext.linkedAt),
                }
              : null,
          createdAt: normalizeOptionalText(item.createdAt),
          updatedAt: normalizeOptionalText(item.updatedAt),
        }))
      : [],
    activeSessionId: normalizeOptionalText(response.activeSessionId),
    roomRequestCounts: response.roomRequestCounts
      ? {
          processingCount: Number.isFinite(response.roomRequestCounts.processingCount) ? response.roomRequestCounts.processingCount : 0,
          unansweredCount: Number.isFinite(response.roomRequestCounts.unansweredCount) ? response.roomRequestCounts.unansweredCount : 0,
        }
      : undefined,
    oldestLoadedMessageId:
      Number.isFinite(response.oldestLoadedMessageId) && Number(response.oldestLoadedMessageId) > 0
        ? Number(response.oldestLoadedMessageId)
        : null,
    hasOlderMessages: response.hasOlderMessages === true,
    promptTarget: response.promptTarget ?? null,
    refreshedAt: response.refreshedAt,
  } satisfies ChatShareSnapshot;
}

export async function submitChatShareMessage(
  token: string,
  text: string,
  options?: {
    sessionId?: string | null;
    mode?: 'queue' | 'direct';
    parentRequestId?: string | null;
    codexModel?: string | null;
  },
) {
  return requestChatApi<{ ok: boolean; queuedRequestId: string }>(
    `/shares/${encodeURIComponent(token)}/messages`,
    {
      method: 'POST',
      body: JSON.stringify({
        text,
        sessionId: options?.sessionId?.trim() || undefined,
        mode: options?.mode === 'direct' ? 'direct' : 'queue',
        parentRequestId: options?.parentRequestId?.trim() || undefined,
        codexModel: options?.codexModel?.trim() || undefined,
      }),
    },
    {
      allowUnauthenticated: true,
    },
  );
}

export async function submitChatShareOriginReply(
  token: string,
  payload: {
    sessionId?: string | null;
    sourceSessionId: string;
    sourceRequestId: string;
    text: string;
    mode?: 'queue' | 'direct';
  },
) {
  return requestChatApi<{ ok: boolean; queuedRequestId: string }>(
    `/shares/${encodeURIComponent(token)}/origin-reply`,
    {
      method: 'POST',
      body: JSON.stringify({
        sessionId: payload.sessionId?.trim() || undefined,
        sourceSessionId: payload.sourceSessionId.trim(),
        sourceRequestId: payload.sourceRequestId.trim(),
        text: payload.text,
        mode: payload.mode === 'direct' ? 'direct' : 'queue',
      }),
    },
    {
      allowUnauthenticated: true,
    },
  );
}

export async function submitChatSharePrompt(
  token: string,
  payload: {
    parentRequestId: string;
    promptIndex: number;
    promptTitle: string;
    promptSignature: string;
    sourceMessageId: number;
    selectedValues: string[];
    freeText?: string | null;
    stepSelections?: Array<{
      stepKey: string;
      stepTitle: string;
      selectedValues: string[];
      freeText: string;
      skipped?: boolean;
    }>;
    summaryText?: string | null;
    attachments?: ChatComposerAttachment[];
    followupText: string;
    mode?: 'queue' | 'direct';
    contextRef?: ChatPromptContextRef | null;
  },
) {
  const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest; message: ChatMessage; queuedRequestId: string }>(
    `/shares/${encodeURIComponent(token)}/prompt-submit`,
    {
      method: 'POST',
      body: JSON.stringify(payload),
    },
    {
      allowUnauthenticated: true,
    },
  );

  return {
    queuedRequestId: response.queuedRequestId,
    item: normalizeChatConversationRequest(response.item),
    message: {
      ...normalizeChatMessage(response.message),
    },
  };
}

export async function completeChatShareManualBadge(
  token: string,
  payload: {
    parentRequestId: string;
    sessionId?: string | null;
    type: 'prompt' | 'verification';
  },
) {
  const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest }>(
    `/shares/${encodeURIComponent(token)}/manual-completion`,
    {
      method: 'POST',
      body: JSON.stringify(payload),
    },
    {
      allowUnauthenticated: true,
    },
  );

  return normalizeChatConversationRequest(response.item);
}

export async function cancelChatShareRequest(
  token: string,
  payload: {
    parentRequestId: string;
    sessionId?: string | null;
  },
) {
  const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest }>(
    `/shares/${encodeURIComponent(token)}/request-cancel`,
    {
      method: 'POST',
      body: JSON.stringify(payload),
    },
    {
      allowUnauthenticated: true,
    },
  );

  return normalizeChatConversationRequest(response.item);
}

export async function retryChatShareRequest(
  token: string,
  payload: {
    parentRequestId: string;
    sessionId?: string | null;
  },
) {
  return requestChatApi<{ ok: boolean; queuedRequestId: string }>(
    `/shares/${encodeURIComponent(token)}/request-retry`,
    {
      method: 'POST',
      body: JSON.stringify(payload),
    },
    {
      allowUnauthenticated: true,
    },
  );
}

type HandleChatServerEventOptions = {
  eventData: string;
  currentPageUrl: string;
  expectedSessionId?: string;
  setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
  onMessageEvent?: (message: ChatMessage, sessionId: string) => void;
  onJobEvent?: (event: ChatJobEvent, sessionId: string) => void;
  onRuntimeEvent?: (snapshot: ChatRuntimeSnapshot) => void;
  onRuntimeDetailEvent?: (detail: ChatRuntimeJobDetail) => void;
  onActivityEvent?: (event: ChatActivityEvent) => void;
  onRequestEvent?: (request: ChatConversationRequest, sessionId: string) => void;
  onEventReceived?: (eventId: number, sessionId: string) => void;
};

function areChatMessagesEquivalent(left: ChatMessage[], right: ChatMessage[]) {
  if (left.length !== right.length) {
    return false;
  }

  return left.every((message, index) => {
    const other = right[index];
    const leftParts = JSON.stringify(message.parts ?? []);
    const rightParts = JSON.stringify(other?.parts ?? []);

    return (
      other &&
      message.id === other.id &&
      message.author === other.author &&
      message.text === other.text &&
      message.timestamp === other.timestamp &&
      leftParts === rightParts
    );
  });
}

export function upsertChatMessage(previous: ChatMessage[], incoming: ChatMessage) {
  const existingIndex = previous.findIndex(
    (message) =>
      message.id === incoming.id ||
      Boolean(
        incoming.clientRequestId &&
          message.clientRequestId &&
          (incoming.author === 'user' || incoming.author === 'codex') &&
          (message.author === 'user' || message.author === 'codex') &&
          incoming.author === message.author &&
          incoming.clientRequestId === message.clientRequestId,
      ),
  );

  if (existingIndex < 0) {
    return [...previous, incoming];
  }

  const nextMessages = [...previous];
  const existingMessage = nextMessages[existingIndex];
  const nextText =
    isActivityLogMessage(existingMessage) && isActivityLogMessage(incoming)
      ? createActivityLogPlaceholder(
          incoming.clientRequestId ?? existingMessage.clientRequestId ?? '',
          mergeActivityLines(getActivityLogLines(existingMessage), getActivityLogLines(incoming)),
        )?.text ?? incoming.text
      : shouldPreserveExistingCodexMessageText(existingMessage, incoming)
        ? existingMessage.text
      : incoming.text;
  nextMessages[existingIndex] = {
    ...existingMessage,
    ...incoming,
    text: nextText,
    parts: incoming.parts ?? existingMessage.parts ?? [],
    deliveryStatus: null,
    retryCount: 0,
  };
  return nextMessages;
}

function isSameChatMessage(left: ChatMessage, right: ChatMessage) {
  if (left.id === right.id) {
    return true;
  }

  if (isActivityLogMessage(left) && isActivityLogMessage(right)) {
    return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
  }

  if (isMissingRequestMessage(left) && isMissingRequestMessage(right)) {
    return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
  }

  if (isExecutionFailureMessage(left) && isExecutionFailureMessage(right)) {
    return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
  }

  return Boolean(
    (left.author === 'user' || left.author === 'codex') &&
      left.author === right.author &&
      left.clientRequestId &&
      right.clientRequestId &&
      left.clientRequestId === right.clientRequestId,
  );
}

function buildComparableChatMessageKey(message: ChatMessage) {
  if (isActivityLogMessage(message) && message.clientRequestId) {
    return `activity:${message.clientRequestId}`;
  }

  if (isMissingRequestMessage(message) && message.clientRequestId) {
    return `missing-request:${message.clientRequestId}`;
  }

  if (isExecutionFailureMessage(message) && message.clientRequestId) {
    return `execution-failure:${message.clientRequestId}`;
  }

  if (message.author === 'user' && message.clientRequestId) {
    return `user-request:${message.clientRequestId}`;
  }

  if (message.author === 'codex' && message.clientRequestId) {
    return `codex-request:${message.clientRequestId}`;
  }

  return `id:${message.id}`;
}

function getComparableChatMessageTime(message: ChatMessage) {
  const parsed = Date.parse(String(message.timestamp ?? '').trim());
  return Number.isFinite(parsed) ? parsed : 0;
}

function getChatMessageRequestId(message: ChatMessage) {
  return message.clientRequestId?.trim() || '';
}

function getChatMessageOrderRank(message: ChatMessage) {
  if (message.author === 'user') {
    return 0;
  }

  if (isMissingRequestMessage(message)) {
    return 1;
  }

  if (isExecutionFailureMessage(message)) {
    return 2;
  }

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

  if (message.author === 'codex') {
    // Keep streamed Codex replies after their request activity rows so the
    // message block does not jump around as timestamps refresh on each update.
    return 4;
  }

  return 5;
}

function sortConversationMessages(messages: ChatMessage[]) {
  if (messages.length <= 1) {
    return messages;
  }

  const messageIndexMap = new Map(messages.map((message, index) => [message, index]));
  const requestOrder = new Map<
    string,
    {
      time: number;
      firstIndex: number;
    }
  >();

  messages.forEach((message, index) => {
    const requestId = getChatMessageRequestId(message);

    if (!requestId) {
      return;
    }

    const time = getComparableChatMessageTime(message);
    const existing = requestOrder.get(requestId);

    if (!existing) {
      requestOrder.set(requestId, {
        time,
        firstIndex: index,
      });
      return;
    }

    requestOrder.set(requestId, {
      time:
        existing.time > 0 && time > 0
          ? Math.min(existing.time, time)
          : existing.time > 0
          ? existing.time
          : time,
      firstIndex: Math.min(existing.firstIndex, index),
    });
  });

  return [...messages].sort((left, right) => {
    const leftRequestId = getChatMessageRequestId(left);
    const rightRequestId = getChatMessageRequestId(right);

    if (leftRequestId && rightRequestId && leftRequestId === rightRequestId) {
      const rankDiff = getChatMessageOrderRank(left) - getChatMessageOrderRank(right);

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

    const leftOrder = leftRequestId ? requestOrder.get(leftRequestId) : null;
    const rightOrder = rightRequestId ? requestOrder.get(rightRequestId) : null;
    const leftTime = leftOrder?.time ?? getComparableChatMessageTime(left);
    const rightTime = rightOrder?.time ?? getComparableChatMessageTime(right);

    if (leftTime !== rightTime) {
      return leftTime - rightTime;
    }

    const leftIndex = leftOrder?.firstIndex ?? messageIndexMap.get(left) ?? 0;
    const rightIndex = rightOrder?.firstIndex ?? messageIndexMap.get(right) ?? 0;

    if (leftIndex !== rightIndex) {
      return leftIndex - rightIndex;
    }

    if (leftRequestId && rightRequestId && leftRequestId !== rightRequestId) {
      const requestDiff = leftRequestId.localeCompare(rightRequestId, 'ko-KR');

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

    const messageTimeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right);

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

    return left.id - right.id;
  });
}

function buildRecoveredMessagesFromConversationDetail(
  requests: ChatConversationRequest[],
  activityLogs: ChatConversationActivityLog[],
) {
  const nextMessages: ChatMessage[] = [];
  const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item]));

  requests.forEach((request) => {
    const requestId = request.requestId.trim();

    if (!requestId || request.status === 'removed') {
      return;
    }

    const userText = String(request.userText ?? '').trim();
    const responseText = String(request.responseText ?? '').trim();
    const activityLog = activityLogMap.get(requestId);
    const failureReason = extractActivityLogFailureReason(activityLog?.lines);
    const shouldReplaceEmptyFailureResponse =
      request.status === 'failed' && isEmptyCodexExecutionResponse(responseText) && Boolean(failureReason);

    if (userText) {
      nextMessages.push({
        id: request.userMessageId ?? createRecoveredMessageId(requestId, 'user'),
        author: 'user',
        text: userText,
        timestamp: request.createdAt || request.updatedAt || '',
        clientRequestId: requestId,
      });
    } else if (responseText || activityLog?.lines.length) {
      nextMessages.push({
        id: createRecoveredMessageId(requestId, 'missing-request'),
        author: 'system',
        text: `${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n이 요청은 저장된 원문이 없어 실제 요청 문장을 표시할 수 없습니다.`,
        timestamp: request.createdAt || request.updatedAt || '',
        clientRequestId: requestId,
      });
    }

    if (responseText && !shouldReplaceEmptyFailureResponse) {
      nextMessages.push({
        id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'codex'),
        author: 'codex',
        text: responseText,
        timestamp: request.answeredAt || request.updatedAt || request.createdAt || '',
        clientRequestId: requestId,
      });
    }

    if (shouldReplaceEmptyFailureResponse) {
      nextMessages.push({
        id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'execution-failure'),
        author: 'system',
        text: buildExecutionFailureMessage(failureReason),
        timestamp: request.answeredAt || request.updatedAt || request.createdAt || '',
        clientRequestId: requestId,
      });
    }

    if (activityLog && activityLog.lines.length > 0) {
      nextMessages.push({
        id: createRecoveredMessageId(requestId, 'activity'),
        author: 'system',
        text: `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${activityLog.lines.join('\n\n')}`,
        timestamp: request.createdAt || request.updatedAt || activityLog.updatedAt || '',
        clientRequestId: requestId,
      });
    }
  });

  return sortConversationMessages(nextMessages);
}

export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: ChatMessage[]) {
  if (previous.length === 0) {
    return incoming;
  }

  if (areChatMessagesEquivalent(previous, incoming)) {
    return previous;
  }

  const previousBuckets = new Map<string, ChatMessage[]>();

  previous.forEach((message) => {
    const key = buildComparableChatMessageKey(message);
    const bucket = previousBuckets.get(key);

    if (bucket) {
      bucket.push(message);
      return;
    }

    previousBuckets.set(key, [message]);
  });

  const consumeExistingMessage = (target: ChatMessage) => {
    const key = buildComparableChatMessageKey(target);
    const bucket = previousBuckets.get(key);

    if (!bucket || bucket.length === 0) {
      return null;
    }

    const matchIndex = bucket.findIndex((message) => isSameChatMessage(message, target));

    if (matchIndex < 0) {
      return null;
    }

    const [matched] = bucket.splice(matchIndex, 1);

    if (bucket.length === 0) {
      previousBuckets.delete(key);
    }

    return matched;
  };

  const mergedServerMessages = incoming.map((serverMessage) => {
    const existingMessage = consumeExistingMessage(serverMessage);

    if (!existingMessage) {
      return serverMessage;
    }

    return {
      ...existingMessage,
      ...serverMessage,
      text: shouldPreserveExistingCodexMessageText(existingMessage, serverMessage)
        ? existingMessage.text
        : serverMessage.text,
      deliveryStatus: null,
      retryCount: 0,
    };
  });

  const incomingUserRequestIds = new Set(
    incoming
      .filter((message) => message.author === 'user')
      .map((message) => getChatMessageRequestId(message))
      .filter(Boolean),
  );
  const unmatchedLocalMessages = Array.from(previousBuckets.values())
    .flat()
    .filter((message) => {
      if (!isMissingRequestMessage(message)) {
        return true;
      }

      const requestId = getChatMessageRequestId(message);

      if (!requestId) {
        return true;
      }

      return !incomingUserRequestIds.has(requestId);
    });
  const nextMessages = sortConversationMessages([...mergedServerMessages, ...unmatchedLocalMessages]);

  return areChatMessagesEquivalent(previous, nextMessages) ? previous : nextMessages;
}

export async function handleChatServerEvent({
  eventData,
  currentPageUrl,
  expectedSessionId,
  setMessages,
  onMessageEvent,
  onJobEvent,
  onRuntimeEvent,
  onRuntimeDetailEvent,
  onActivityEvent,
  onRequestEvent,
  onEventReceived,
}: HandleChatServerEventOptions) {
  try {
    const payload = JSON.parse(eventData) as ChatServerEvent;

    // Ignore late events from a previously selected conversation so they don't
    // overwrite or interleave with the active thread.
    if (expectedSessionId && payload.sessionId !== expectedSessionId) {
      return;
    }

    onEventReceived?.(payload.eventId, payload.sessionId);

    if (payload.type === 'chat:init') {
      const normalizedMessages = Array.isArray(payload.payload.messages)
        ? payload.payload.messages.map((message, index) => normalizeChatMessage(message, index))
        : [];
      setMessages((previous) => mergeRecoveredChatMessages(previous, normalizedMessages));
      return;
    }

    if (payload.type === 'chat:message') {
      const normalizedMessage = normalizeChatMessage(payload.payload);
      onMessageEvent?.(normalizedMessage, payload.sessionId);
      setMessages((previous) => upsertChatMessage(previous, normalizedMessage));
      return;
    }

    if (payload.type === 'chat:message:update') {
      const normalizedMessage = normalizeChatMessage(payload.payload);
      setMessages((previous) => upsertChatMessage(previous, normalizedMessage));
      return;
    }

    if (payload.type === 'chat:job') {
      onJobEvent?.(payload.payload, payload.sessionId);
      return;
    }

    if (payload.type === 'chat:runtime') {
      onRuntimeEvent?.(payload.payload);
      return;
    }

    if (payload.type === 'chat:runtime:detail') {
      onRuntimeDetailEvent?.(payload.payload);
      return;
    }

    if (payload.type === 'chat:activity') {
      onActivityEvent?.(payload.payload);
      setMessages((previous) => appendActivityEventToMessages(previous, payload.payload));
      return;
    }

    if (payload.type === 'chat:request:update') {
      onRequestEvent?.(normalizeChatConversationRequest(payload.payload), payload.sessionId);
      return;
    }

    if (payload.type === 'notification:messages-updated') {
      notifyNotificationMessagesUpdated();
      return;
    }

    if (payload.type === 'chat:error') {
      setMessages((previous) => [...previous, createLocalMessage(payload.payload.message)]);
    }
  } catch {
    await reportClientError({
      errorType: 'chat:message-parse',
      errorMessage: '채팅 응답 JSON 파싱에 실패했습니다.',
      requestPath: currentPageUrl || null,
      context: {
        rawMessage: eventData.slice(0, 2000),
      },
    });
    setMessages((previous) => [
      ...previous,
      createLocalMessage('채팅 응답을 해석하지 못했습니다. 연결을 다시 확인합니다.'),
    ]);
  }
}
