import { chmod, cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { FastifyBaseLogger } from 'fastify';
import type { IncomingMessage } from 'node:http';
import type { Socket } from 'node:net';
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
import { env } from '../config/env.js';
import { db } from '../db/client.js';
import {
  createDefaultChatTypeExecutionPolicy,
  getAppConfigSnapshot,
  getChatContextSettingsConfig,
  getChatTypesConfig,
  type ChatTypeExecutionPolicy,
} from './app-config-service.js';
import { BOARD_POSTS_TABLE } from './board-service.js';
import {
  appendChatConversationMessage,
  appendChatConversationActivityLine,
  getChatConversationRequest,
  type ChatConversationRequestItem,
  type ChatConversationRequestUsageSnapshot,
  getChatConversation,
  listChatConversationOfflineNotificationClientIds,
  listRecoverableChatConversationRequests,
  listChatConversationMessages,
  listChatConversationRequests,
  shouldClearConversationJobState,
  upsertChatConversationRequest,
  updateChatConversationJobState,
  updateChatConversationContext,
} from './chat-room-service.js';
import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot } from './chat-runtime-service.js';
import { hasErrorLogViewAccessToken } from './error-log-service.js';
import {
  getSharedResourceTokenDetailBySharePath,
  validateSharedResourceAccessPinBySharePath,
} from './shared-resource-token-service.js';
import { getChatShareTokenRoomMap } from './chat-share-room-map-service.js';
import { sendNotifications } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js';
import {
  subscribeNotificationMessageChanges,
  type NotificationMessageChangeEvent,
} from './notification-message-service.js';
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
import { isRuntimeDraining, trackWebSocketConnectionClosed, trackWebSocketConnectionOpened } from './runtime-drain-service.js';
import { isCurrentWorkServerSlotActive } from './work-server-slot-service.js';
import {
  findLatestPlanItem,
  findPlanItemByPreviewUrl,
  findPlanItemByWorkId,
  getPlanItemById,
  listPlanActionHistories,
  listPlanIssueHistories,
  listPlanSourceWorkHistories,
  mapPlanActionRow,
  mapPlanIssueRow,
  mapPlanSourceWorkRow,
  PLAN_TABLE,
} from './plan-service.js';

type ChatAuthor = 'codex' | 'system' | 'user';
type CodexParticipantRole = 'default' | 'moderator' | 'conversation' | 'reviewer';
type CodexParticipantTurn = 'standard' | 'opening' | 'discussion' | 'review' | 'closing';
type ChatRequestMode = 'queue' | 'direct';

type ChatMessage = {
  id: number;
  author: ChatAuthor;
  text: string;
  timestamp: string;
  clientRequestId?: string | null;
  parts?: ChatMessagePart[];
};

type ChatContext = {
  pageId: string | null;
  pageTitle: string;
  topMenu: string;
  focusedComponentId: string | null;
  pageUrl: string;
  appOrigin?: string;
  appDomain?: string;
  isStandaloneMode?: boolean;
  pageVisibilityState?: 'visible' | 'hidden';
  pageFocusState?: 'focused' | 'blurred';
  codexModel?: string | null;
  codexParticipants?: Array<{
    id?: string;
    name?: string;
    model?: string;
    prompt?: string;
    chatTypeId?: string | null;
    defaultContextIds?: string[];
    role?: CodexParticipantRole | null;
  }>;
  chatTypeId?: string | null;
  chatTypeLabel?: string;
  chatTypeDescription?: string;
  chatTypeBaseDescription?: string;
  chatTypeExecutionPolicy?: ChatTypeExecutionPolicy;
  defaultContextIds?: string[];
  defaultContexts?: Array<{
    id?: string;
    title?: string;
    content?: string;
  }>;
  customContextTitle?: string | null;
  customContextContent?: string | null;
};

export function sanitizeChatContextOverride(contextOverride?: Partial<ChatContext> | null) {
  if (!contextOverride) {
    return null;
  }

  const normalizedOverride = {
    ...contextOverride,
  };

  if (
    Object.prototype.hasOwnProperty.call(normalizedOverride, 'codexModel')
    && normalizedOverride.codexModel === undefined
  ) {
    delete normalizedOverride.codexModel;
  }

  return normalizedOverride;
}

type ChatPromptContextRef = {
  key: 'prompt_parent_question';
  promptTitle: string;
  promptDescription?: string | null;
  parentQuestionText?: string | null;
};

type ChatInboundMessage =
  | {
      type: 'context:update';
      payload: ChatContext;
    }
  | {
      type: 'presence:ping';
      payload?: {
        at?: number;
      };
    }
  | {
      type: 'event:received';
      payload: {
        eventId: number;
      };
    }
  | {
      type: 'message:send';
      payload: {
        sessionId?: string;
        text: string;
        requestId?: string;
        mode?: 'queue' | 'direct';
        requestOrigin?: 'composer' | 'prompt';
        sharedResourceTokenId?: string | null;
        parentRequestId?: string | null;
        promptContextRef?: ChatPromptContextRef | null;
        omitPromptHistory?: boolean;
        codexModel?: string | null;
        codexParticipants?: Array<{
          id?: string;
          name?: string;
          model?: string;
          prompt?: string;
          chatTypeId?: string | null;
          defaultContextIds?: string[];
          role?: CodexParticipantRole | null;
        }>;
        chatTypeId?: string | null;
        chatTypeLabel?: string;
        chatTypeDescription?: string;
        chatTypeBaseDescription?: string;
        chatTypeExecutionPolicy?: ChatTypeExecutionPolicy;
        defaultContextIds?: string[];
        defaultContexts?: Array<{
          id?: string;
          title?: string;
          content?: string;
        }>;
        customContextTitle?: string | null;
        customContextContent?: string | null;
      };
    }
  | {
      type: 'runtime:watch';
      payload: {
        requestId?: string | null;
      };
    };

type ChatOutboundPayload =
  | {
      type: 'chat:init';
      payload: {
        messages: ChatMessage[];
      };
    }
  | {
      type: 'chat:message';
      payload: ChatMessage;
    }
  | {
      type: 'chat:message:update';
      payload: ChatMessage;
    }
  | {
      type: 'chat:status';
      payload: {
        connectedAt: string;
      };
    }
  | {
      type: 'chat:job';
      payload: {
        requestId: string;
        status: 'queued' | 'started' | 'completed' | 'failed';
        mode: 'queue' | 'direct';
        queueSize: number;
        message: string;
      };
    }
  | {
      type: 'chat:error';
      payload: {
        message: string;
      };
    }
  | {
      type: 'chat:runtime';
      payload: ChatRuntimeSnapshot;
    }
  | {
      type: 'chat:runtime:detail';
      payload: ChatRuntimeJobDetail;
    }
  | {
      type: 'chat:activity';
      payload: {
        requestId: string;
        line: string;
        lineCount: number;
        lineNo?: number;
      };
    }
  | {
      type: 'chat:request:update';
      payload: ChatConversationRequestItem;
    }
  | {
      type: 'notification:messages-updated';
      payload:
        | {
            action: 'created' | 'updated';
            itemId: number;
            category: string;
            read: boolean;
          }
        | {
            action: 'deleted';
            itemId: number;
          };
    };

type ChatOutboundMessage = ChatOutboundPayload & {
  eventId: number;
  sessionId: string;
};

type ChatSessionState = {
  sessionId: string;
  clientId: string | null;
  sockets: Set<WebSocket>;
  lastSeenAt: number;
  isDeleted: boolean;
  context: ChatContext | null;
  queue: Array<{
    requestId: string;
    text: string;
    mode: 'queue' | 'direct';
    requestedAtMs: number;
    requestOrigin?: 'composer' | 'prompt';
    sharedResourceTokenId?: string | null;
    parentRequestId?: string | null;
    omitPromptHistory?: boolean;
    context: ChatContext | null;
  }>;
  activeRequestCount: number;
  pendingQueueReleaseEventId: number | null;
  pendingQueueReleaseTimer: ReturnType<typeof setTimeout> | null;
  nextEventId: number;
  eventHistory: ChatOutboundMessage[];
  messagePersistenceTail: Promise<void>;
  watchedRuntimeRequestId: string | null;
};

type ChatRuntimeController = {
  getJobDetail: (requestId: string) => ReturnType<typeof chatRuntimeService.getJobDetail>;
  cancelJob: (requestId: string) => Promise<boolean>;
  removeQueuedJob: (requestId: string) => Promise<boolean>;
};

type ActiveChatExecution = {
  cancel: () => Promise<boolean> | boolean;
};

let activeRuntimeController: ChatRuntimeController | null = null;
let activeChatService: ChatService | null = null;
const activeChatProcessRegistry = new Map<string, ActiveChatExecution>();
const QUEUE_RELEASE_FALLBACK_DELAY_MS = 1500;

export function getChatRuntimeController() {
  return activeRuntimeController;
}

export function getActiveChatService() {
  return activeChatService;
}

type ChatServiceRuntimeSnapshot = {
  activeRequestCount: number;
  queuedRequestCount: number;
  connectedSessionCount: number;
  activeSocketCount: number;
  canAcceptNewRequests: boolean;
};

function getSessionSocketReadyState(session: ChatSessionState) {
  for (const socket of session.sockets) {
    if (socket.readyState === SOCKET_READY_STATE_OPEN) {
      return socket.readyState;
    }
  }

  for (const socket of session.sockets) {
    return socket.readyState;
  }

  return null;
}

function hasOpenSessionSocket(session: ChatSessionState) {
  for (const socket of session.sockets) {
    if (socket.readyState === SOCKET_READY_STATE_OPEN) {
      return true;
    }
  }

  return false;
}

const SOCKET_PATH = '/ws/chat';
const KST_TIME_ZONE = 'Asia/Seoul';
const STREAM_CAPTURE_LIMIT = 256 * 1024;
const CHAT_PUBLIC_RESOURCE_DIR = '.codex_chat';
const CHAT_PUBLIC_RESOURCE_SUBDIR = 'resource';
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
const CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH = 'source/chat-room-reference.md';
const CHAT_SESSION_REFERENCE_AUTO_START = '<!-- codex-live:auto:start -->';
const CHAT_SESSION_REFERENCE_AUTO_END = '<!-- codex-live:auto:end -->';
const CHAT_SESSION_RESOURCE_DIR_MODE = 0o777;
const SOCKET_READY_STATE_OPEN = 1;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const MAX_CHAT_ACTIVITY_MESSAGE_LINES = 240;
const MAX_CHAT_ACTIVITY_MESSAGE_CHARS = 80_000;
const CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS = 15_000;
const CHAT_PROMPT_HISTORY_MAX_MESSAGES = 12;
const CHAT_PROMPT_HISTORY_MAX_CHARS = 3200;
const CHAT_SESSION_EVENT_HISTORY_LIMIT = 400;
let chatMessageSequence = 0;

export function isChatClientActivelyViewing(clientId: string | null | undefined, sessions: Iterable<ChatSessionState>) {
  return evaluateChatClientActiveViewing(clientId, sessions).isActive;
}

function evaluateChatClientActiveViewing(clientId: string | null | undefined, sessions: Iterable<ChatSessionState>, nowMs = Date.now()) {
  const normalizedClientId = clientId?.trim();

  if (!normalizedClientId) {
    return {
      isActive: false,
      matchedSessions: [],
    };
  }

  const matchedSessions: Array<{
    sessionId: string;
    lastSeenAt: number;
    presenceAgeMs: number;
    socketReadyState: number | null;
    pageVisibilityState: 'visible' | 'hidden';
    pageFocusState: 'focused' | 'blurred';
    pageOrigin: string | null;
    reason:
      | 'socket-not-open'
      | 'stale-presence'
      | 'backgrounded'
      | 'active-no-page-url'
      | 'active-supported-origin'
      | 'unsupported-origin'
      | 'invalid-page-url';
  }> = [];

  for (const session of sessions) {
    if (session.clientId?.trim() !== normalizedClientId) {
      continue;
    }

    const pageVisibilityState = session.context?.pageVisibilityState ?? 'visible';
    const pageFocusState = session.context?.pageFocusState ?? 'focused';
    const pageUrl = session.context?.pageUrl?.trim();
    const presenceAgeMs = Math.max(0, nowMs - session.lastSeenAt);
    const socketReadyState = getSessionSocketReadyState(session);

    if (!hasOpenSessionSocket(session)) {
      matchedSessions.push({
        sessionId: session.sessionId,
        lastSeenAt: session.lastSeenAt,
        presenceAgeMs,
        socketReadyState,
        pageVisibilityState,
        pageFocusState,
        pageOrigin: null,
        reason: 'socket-not-open',
      });
      continue;
    }

    const hasFreshPresence = presenceAgeMs < CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS;

    if (!hasFreshPresence) {
      matchedSessions.push({
        sessionId: session.sessionId,
        lastSeenAt: session.lastSeenAt,
        presenceAgeMs,
        socketReadyState,
        pageVisibilityState,
        pageFocusState,
        pageOrigin: null,
        reason: 'stale-presence',
      });
      continue;
    }

    if (pageVisibilityState === 'hidden' || pageFocusState === 'blurred') {
      matchedSessions.push({
        sessionId: session.sessionId,
        lastSeenAt: session.lastSeenAt,
        presenceAgeMs,
        socketReadyState,
        pageVisibilityState,
        pageFocusState,
        pageOrigin: null,
        reason: 'backgrounded',
      });
      continue;
    }

    if (!pageUrl) {
      matchedSessions.push({
        sessionId: session.sessionId,
        lastSeenAt: session.lastSeenAt,
        presenceAgeMs,
        socketReadyState,
        pageVisibilityState,
        pageFocusState,
        pageOrigin: null,
        reason: 'active-no-page-url',
      });
      return {
        isActive: true,
        matchedSessions,
      };
    }

    try {
      const resolvedUrl = new URL(pageUrl);

      if (resolvedUrl.origin === 'https://preview.sm-home.cloud' || resolvedUrl.origin === 'https://test.sm-home.cloud') {
        matchedSessions.push({
          sessionId: session.sessionId,
          lastSeenAt: session.lastSeenAt,
          presenceAgeMs,
          socketReadyState,
          pageVisibilityState,
          pageFocusState,
          pageOrigin: resolvedUrl.origin,
          reason: 'active-supported-origin',
        });
        return {
          isActive: true,
          matchedSessions,
        };
      }

      matchedSessions.push({
        sessionId: session.sessionId,
        lastSeenAt: session.lastSeenAt,
        presenceAgeMs,
        socketReadyState,
        pageVisibilityState,
        pageFocusState,
        pageOrigin: resolvedUrl.origin,
        reason: 'unsupported-origin',
      });
    } catch {
      matchedSessions.push({
        sessionId: session.sessionId,
        lastSeenAt: session.lastSeenAt,
        presenceAgeMs,
        socketReadyState,
        pageVisibilityState,
        pageFocusState,
        pageOrigin: null,
        reason: 'invalid-page-url',
      });
      continue;
    }
  }

  return {
    isActive: false,
    matchedSessions,
  };
}

function logChatClientActiveViewingEvaluation(
  logger: FastifyBaseLogger,
  clientId: string,
  evaluation: ReturnType<typeof evaluateChatClientActiveViewing>,
) {
  if (evaluation.matchedSessions.length === 0) {
    logger.info(
      {
        clientId,
        active: false,
        reason: 'no-matching-session',
      },
      'chat offline notification presence evaluation',
    );
    return;
  }

  for (const sessionState of evaluation.matchedSessions) {
    logger.info(
      {
        clientId,
        active: evaluation.isActive,
        sessionId: sessionState.sessionId,
        lastSeenAt: sessionState.lastSeenAt,
        presenceAgeMs: sessionState.presenceAgeMs,
        socketReadyState: sessionState.socketReadyState,
        pageVisibilityState: sessionState.pageVisibilityState,
        pageFocusState: sessionState.pageFocusState,
        pageOrigin: sessionState.pageOrigin,
        reason: sessionState.reason,
      },
      'chat offline notification presence evaluation',
    );
  }
}

export function resolveChatContextAppOrigin(context: ChatContext | null | undefined) {
  const directOrigin = context?.appOrigin?.trim();

  if (directOrigin) {
    try {
      return new URL(directOrigin).origin;
    } catch {
      return null;
    }
  }

  const pageUrl = context?.pageUrl?.trim();

  if (!pageUrl) {
    return null;
  }

  try {
    return new URL(pageUrl).origin;
  } catch {
    return null;
  }
}

export function resolveChatContextAppDomain(context: ChatContext | null | undefined) {
  const directDomain = context?.appDomain?.trim().toLowerCase();

  if (directDomain) {
    return directDomain;
  }

  const appOrigin = resolveChatContextAppOrigin(context);

  if (!appOrigin) {
    return null;
  }

  try {
    return new URL(appOrigin).hostname.trim().toLowerCase() || null;
  } catch {
    return null;
  }
}

function buildChatNotificationTargetUrl(context: ChatContext | null, sessionId: string) {
  const fallbackUrl = new URL('/chat/live', resolveChatContextAppOrigin(context) ?? 'https://preview.sm-home.cloud');
  fallbackUrl.searchParams.set('topMenu', 'chat');
  fallbackUrl.searchParams.set('sessionId', sessionId);

  const pageUrl = context?.pageUrl?.trim();

  if (!pageUrl) {
    return fallbackUrl.toString();
  }

  try {
    const targetUrl = new URL(pageUrl);
    targetUrl.pathname = '/chat/live';
    targetUrl.searchParams.set('topMenu', targetUrl.searchParams.get('topMenu') || 'chat');
    targetUrl.searchParams.set('sessionId', sessionId);
    targetUrl.searchParams.delete('chatView');
    targetUrl.searchParams.delete('runtimeRequestId');
    return targetUrl.toString();
  } catch {
    return fallbackUrl.toString();
  }
}

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

function createChatQuestionAnswerNotificationBody(args: {
  questionText?: string | null;
  answerText?: string | null;
  fallback: string;
}) {
  const questionPreview = createChatNotificationPreview(args.questionText ?? '');
  const answerPreview = createChatNotificationPreview(args.answerText ?? '');

  if (questionPreview && answerPreview) {
    return `질문: ${questionPreview}\n답변: ${answerPreview}`;
  }

  if (answerPreview) {
    return `답변: ${answerPreview}`;
  }

  if (questionPreview) {
    return `질문: ${questionPreview}`;
  }

  return args.fallback;
}

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

  const existingParts = Array.isArray(message.parts) ? message.parts.filter(Boolean) : [];
  const extracted = extractChatMessageParts(message.text);
  const nextParts = existingParts.length > 0 ? existingParts : extracted.parts;

  if (nextParts.length === 0) {
    return existingParts.length === 0 ? message : { ...message, parts: existingParts };
  }

  return {
    ...message,
    text: extracted.strippedText,
    parts: nextParts,
  };
}

function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) {
  const questionPreview = createChatNotificationPreview(questionText ?? '');
  return questionPreview ? `질문: ${questionPreview}` : fallback ?? '';
}

function normalizeNotificationDetailText(text?: string | null) {
  const normalized = String(text ?? '').trim();
  return normalized || undefined;
}

export function collectOfflineNotificationClientIds(preferredClientIds?: string[]) {
  const nextClientIds = new Set<string>();

  for (const candidate of preferredClientIds ?? []) {
    const normalized = String(candidate ?? '').trim();

    if (normalized) {
      nextClientIds.add(normalized);
    }
  }

  return [...nextClientIds];
}

export function filterInactiveOfflineNotificationClientIds(
  clientIds: string[],
  sessions: Iterable<ChatSessionState>,
  isActive: (clientId: string, currentSessions: Iterable<ChatSessionState>) => boolean = isChatClientActivelyViewing,
) {
  return clientIds.filter((clientId) => !isActive(clientId, sessions));
}

export function shouldSendOfflineChatNotification(options: {
  receiveRoomNotifications?: boolean | null;
  conversationNotifyOffline?: boolean | null;
}) {
  if (options.receiveRoomNotifications === false) {
    return false;
  }

  return options.conversationNotifyOffline === true;
}

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

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

export function resolveResponseTimestamp(requestedAtMs?: number | null, nowMs = Date.now()) {
  if (!Number.isFinite(requestedAtMs)) {
    return formatTime(new Date(nowMs));
  }

  return formatTime(new Date(Math.max(nowMs, Number(requestedAtMs) + 1_000)));
}

function parseRequestedAtMs(value: string) {
  const parsed = Date.parse(value);
  return Number.isFinite(parsed) ? parsed : Date.now();
}

function formatKstDate(date = new Date()) {
  return new Intl.DateTimeFormat('en-CA', {
    timeZone: KST_TIME_ZONE,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  }).format(date);
}

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

function createMessage(author: ChatAuthor, text: string, clientRequestId?: string | null, parts?: ChatMessagePart[]): ChatMessage {
  return {
    id: createChatMessageId(),
    author,
    text,
    timestamp: formatTime(new Date()),
    clientRequestId: clientRequestId?.trim() || null,
    parts: Array.isArray(parts) ? parts : [],
  };
}

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

async function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL) {
  const queryToken = url.searchParams.get('accessToken')?.trim();
  const headerToken = Array.isArray(request.headers['x-access-token'])
    ? String(request.headers['x-access-token'][0] ?? '').trim()
    : String(request.headers['x-access-token'] ?? '').trim();

  if (hasErrorLogViewAccessToken(queryToken || headerToken)) {
    return true;
  }

  const shareToken = url.searchParams.get('shareToken')?.trim() || '';
  const sharePin = url.searchParams.get('sharePin')?.trim() || '';
  const clientId = url.searchParams.get('clientId')?.trim() || '';
  const requestedSessionId = url.searchParams.get('sessionId')?.trim() || '';

  if (!shareToken) {
    return false;
  }

  const sharePath = '/chat/share/' + encodeURIComponent(shareToken);
  const sharedTokenDetail = await getSharedResourceTokenDetailBySharePath(sharePath);
  const sharedToken = sharedTokenDetail?.token ?? null;
  const resourceContext = sharedToken?.resourceContext ?? null;

  if (!resourceContext) {
    return false;
  }

  if (requestedSessionId && resourceContext.sessionId !== requestedSessionId) {
    const requestedRoom = sharedToken?.id ? await getChatShareTokenRoomMap(sharedToken.id, requestedSessionId) : null;

    if (!requestedRoom) {
      return false;
    }
  }

  if (!sharedToken || !sharedToken.enabled || sharedToken.sharePath !== sharePath) {
    return false;
  }

  if (sharedToken.effectiveExpiresAt) {
    const expiresAtMs = Date.parse(sharedToken.effectiveExpiresAt);

    if (Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now()) {
      return false;
    }
  }

  const pinStatus = await validateSharedResourceAccessPinBySharePath(sharePath, sharePin, {
    clientId,
  });

  if (pinStatus.status !== 'ok' && pinStatus.status !== 'not-configured') {
    return false;
  }

  return true;
}

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 fitActivityLogLines(lines: string[]) {
  const normalizedLines = lines.map((line) => line.trim()).filter(Boolean);

  if (normalizedLines.length <= 1) {
    return normalizedLines;
  }

  let startIndex = Math.max(0, normalizedLines.length - MAX_CHAT_ACTIVITY_MESSAGE_LINES);
  let fittedLines = normalizedLines.slice(startIndex);

  while (fittedLines.length > 1 && fittedLines.join('\n\n').length > MAX_CHAT_ACTIVITY_MESSAGE_CHARS) {
    startIndex += 1;
    fittedLines = normalizedLines.slice(startIndex);
  }

  return fittedLines;
}

export function createActivityLogMessage(requestId: string, lines: string[]) {
  const normalizedRequestId = requestId.trim();
  const fittedLines = fitActivityLogLines(lines);
  const messageBody = fittedLines.length > 0 ? fittedLines.join('\n\n') : '활동 로그를 준비하고 있습니다.';
  const messageText = `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${messageBody}`;

  return createMessage('system', messageText, normalizedRequestId)
    ? {
        id: hashRequestId(normalizedRequestId),
        author: 'system' as const,
        text: messageText,
        timestamp: formatTime(new Date()),
        clientRequestId: normalizedRequestId,
      }
    : null;
}

function shouldPersistMessageUpdate(message: ChatMessage) {
  if (message.author === 'codex') {
    return false;
  }

  return !String(message.text ?? '').startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
}

function isSocketOpen(socket: WebSocket | null | undefined) {
  return Boolean(socket && socket.readyState === SOCKET_READY_STATE_OPEN);
}

function closeSocketSafely(
  logger: FastifyBaseLogger,
  socket: WebSocket | null | undefined,
  message: string,
  code = 1000,
  reason = 'replaced',
) {
  if (!socket) {
    return;
  }

  try {
    socket.close(code, reason);
  } catch (error) {
    logger.warn(error, message);
  }
}

function sendSocketEnvelope(
  logger: FastifyBaseLogger,
  socket: WebSocket | null | undefined,
  envelope: ChatOutboundMessage,
  message: string,
) {
  const targetSocket = socket;

  if (!targetSocket || targetSocket.readyState !== SOCKET_READY_STATE_OPEN) {
    return false;
  }

  try {
    targetSocket.send(JSON.stringify(envelope));
    return true;
  } catch (error) {
    logger.warn(error, message);
    return false;
  }
}

type ParsedPlanContext = {
  planId: number | null;
  workId: string | null;
  previewUrl: string | null;
};

type PlanSnapshot = {
  planId: number;
  workId: string;
  status: string;
  note: string;
  workerStatus: string | null;
  issueTags: string[];
  hasOpenIssues: boolean;
  assignedBranch: string | null;
  lastError: string | null;
  latestActionNote: string | null;
  latestIssue: {
    tag: string;
    message: string;
    resolved: boolean;
  } | null;
  latestSourceWork: {
    summary: string;
    previewUrl: string | null;
    changedFiles: string[];
  } | null;
  recentActionNotes: string[];
  recentWorkSummaries: string[];
};

function truncateText(value: string | null | undefined, limit = 120) {
  const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();

  if (!normalized) {
    return '';
  }

  return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
}

function extractReferencedPlanId(input: string) {
  const planIdMatch = input.match(/(?:planid=|#)(\d{1,9})/i) ?? input.match(/\bplan\s*(\d{1,9})\b/i);
  const parsedPlanId = planIdMatch?.[1] ? Number(planIdMatch[1]) : NaN;

  return Number.isInteger(parsedPlanId) && parsedPlanId > 0 ? parsedPlanId : null;
}

function extractReferencedWorkId(input: string) {
  const workIdMatch = input.match(/\bworkid\s*[:=]?\s*([^\s,]+)/i);
  return workIdMatch?.[1]?.trim() || null;
}

function extractReferencedPreviewUrl(input: string) {
  const previewUrlMatch = input.match(/https?:\/\/[^\s)]+/i);
  return previewUrlMatch?.[0]?.trim() || null;
}

function buildChangedFilesSummary(changedFiles: string[], limit = 5) {
  if (changedFiles.length === 0) {
    return '변경/신규 파일: 기록 없음';
  }

  const visibleFiles = changedFiles.slice(0, limit);
  const suffix = changedFiles.length > limit ? ` 외 ${changedFiles.length - limit}개` : '';
  return `변경/신규 파일: ${visibleFiles.join(', ')}${suffix}`;
}

function parsePlanContext(context: ChatContext | null, input: string): ParsedPlanContext {
  const pageUrl = context?.pageUrl ?? '';
  const referencedPlanId = extractReferencedPlanId(input);
  const referencedWorkId = extractReferencedWorkId(input);
  const referencedPreviewUrl = extractReferencedPreviewUrl(input);

  if (!pageUrl) {
    return {
      planId: referencedPlanId,
      workId: referencedWorkId,
      previewUrl: referencedPreviewUrl,
    };
  }

  try {
    const url = new URL(pageUrl);
    const planIdText = url.searchParams.get('planId');
    const workIdText = url.searchParams.get('workId');
    const planIdNumber = planIdText ? Number(planIdText) : NaN;

    return {
      planId: Number.isInteger(planIdNumber) && planIdNumber > 0 ? planIdNumber : referencedPlanId,
      workId: workIdText?.trim() || referencedWorkId,
      previewUrl: referencedPreviewUrl,
    };
  } catch {
    return {
      planId: referencedPlanId,
      workId: referencedWorkId,
      previewUrl: referencedPreviewUrl,
    };
  }
}

async function loadPlanSnapshot(planId: number): Promise<PlanSnapshot | null> {
  const item = await getPlanItemById(planId);

  if (!item) {
    return null;
  }

  const [actionRows, issueRows, sourceWorkRows] = await Promise.all([
    listPlanActionHistories(planId),
    listPlanIssueHistories(planId),
    listPlanSourceWorkHistories(planId),
  ]);
  const latestAction = actionRows[0] ? mapPlanActionRow(actionRows[0]) : null;
  const latestIssue = issueRows[0] ? mapPlanIssueRow(issueRows[0]) : null;
  const latestSourceWork = sourceWorkRows[0] ? mapPlanSourceWorkRow(sourceWorkRows[0]) : null;

  return {
    planId,
    workId: String(item.workId ?? '작업ID'),
    status: String(item.status ?? '-'),
    note: String(item.note ?? ''),
    workerStatus: item.workerStatus ? String(item.workerStatus) : null,
    issueTags: Array.isArray(item.issueTags) ? item.issueTags.map((value) => String(value)) : [],
    hasOpenIssues: Boolean(item.hasOpenIssues),
    assignedBranch: item.assignedBranch ? String(item.assignedBranch) : null,
    lastError: item.lastError ? String(item.lastError) : null,
    latestActionNote: latestAction ? String(latestAction.note) : null,
    latestIssue: latestIssue
      ? {
          tag: String(latestIssue.issueTag),
          message: String(latestIssue.message),
          resolved: Boolean(latestIssue.resolved),
        }
      : null,
    latestSourceWork: latestSourceWork
      ? {
          summary: String(latestSourceWork.summary),
          previewUrl: latestSourceWork.previewUrl ? String(latestSourceWork.previewUrl) : null,
          changedFiles: latestSourceWork.changedFiles,
        }
      : null,
    recentActionNotes: actionRows
      .slice(0, 5)
      .map((row) => truncateText(String(mapPlanActionRow(row).note ?? ''), 140))
      .filter(Boolean),
    recentWorkSummaries: sourceWorkRows
      .slice(0, 5)
      .map((row) => truncateText(String(mapPlanSourceWorkRow(row).summary ?? ''), 140))
      .filter(Boolean),
  };
}

function isWorklogRequest(input: string) {
  const normalized = input.toLowerCase();
  const mentionsWorklog =
    input.includes('워크일지') ||
    input.includes('작업로그') ||
    input.includes('작업 일지') ||
    input.includes('작업일지') ||
    normalized.includes('worklog');
  const asksToWrite =
    input.includes('작성') ||
    input.includes('정리') ||
    input.includes('기록') ||
    input.includes('밀린') ||
    input.includes('써');

  return mentionsWorklog || (normalized.includes('log') && asksToWrite);
}

function isPlanDetailRequest(input: string) {
  const normalized = input.toLowerCase();

  return (
    input.includes('작업') ||
    input.includes('계획') ||
    input.includes('이력') ||
    input.includes('이슈') ||
    input.includes('상태') ||
    input.includes('브랜치') ||
    normalized.includes('plan') ||
    normalized.includes('issue') ||
    normalized.includes('status') ||
    normalized.includes('history') ||
    normalized.includes('branch')
  );
}

function buildWorklogReply(context: ChatContext | null, snapshot: PlanSnapshot | null) {
  const pageTitle = context?.pageTitle ?? '현재 화면';

  if (!snapshot) {
    return `현재 화면: ${pageTitle}\n작업로그 초안을 만들 Plan을 찾지 못했습니다. Plan 상세를 연 뒤 다시 요청해 주세요.`;
  }

  const today = new Intl.DateTimeFormat('en-CA', {
    timeZone: 'Asia/Seoul',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  }).format(new Date());
  const todayWork = [
    truncateText(snapshot.note, 180),
    ...snapshot.recentWorkSummaries,
    snapshot.latestActionNote ? truncateText(snapshot.latestActionNote, 140) : '',
  ].filter(Boolean);
  const issues = snapshot.latestIssue
    ? [
        `${snapshot.latestIssue.tag} ${snapshot.latestIssue.resolved ? '해결' : '미해결'}: ${truncateText(
          snapshot.latestIssue.message,
          140,
        )}`,
      ]
    : [];
  const decisions = [
    snapshot.assignedBranch ? `작업 브랜치: ${snapshot.assignedBranch}` : '',
    snapshot.latestSourceWork?.previewUrl ? `preview 링크: ${snapshot.latestSourceWork.previewUrl}` : '',
    snapshot.status ? `현재 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}` : '',
  ].filter(Boolean);
  const details = [...snapshot.recentActionNotes, ...snapshot.recentWorkSummaries].filter(Boolean);

  return [
    `현재 화면: ${pageTitle}`,
    `기준 Plan: #${snapshot.planId} ${snapshot.workId}`,
    '아래 초안을 작업일지에 바로 붙여 넣으면 됩니다.',
    '',
    `# ${today} 작업일지`,
    '',
    '## 오늘 작업',
    ...(todayWork.length > 0 ? todayWork.slice(0, 6).map((item) => `- ${item}`) : ['- Plan 메모와 이력 확인 후 작업 내용을 보강해 주세요.']),
    '',
    '## 이슈 및 해결',
    ...(issues.length > 0 ? issues.map((item) => `- ${item}`) : ['- 현재 기록된 이슈 없음']),
    '',
    '## 결정 사항',
    ...(decisions.length > 0 ? decisions.map((item) => `- ${item}`) : ['- 별도 결정 사항 없음']),
    '',
    '## 상세 작업 내역',
    ...(details.length > 0 ? details.slice(0, 8).map((item) => `- ${item}`) : ['- 최근 조치 이력이 없어 상세 내역 보강이 필요합니다.']),
  ].join('\n');
}

function buildRecentPlanHistoryLines(snapshot: PlanSnapshot | null, limit = 2) {
  if (!snapshot) {
    return [];
  }

  const historyItems = [
    ...snapshot.recentActionNotes.map((note) => `[조치] ${note}`),
    ...snapshot.recentWorkSummaries.map((summary) => `[소스] ${summary}`),
  ]
    .filter(Boolean)
    .slice(0, limit);

  if (historyItems.length === 0) {
    return [];
  }

  return ['최근 조치 이력:', ...historyItems.map((item, index) => `${index + 1}. ${item}`)];
}

function isDetailRequest(input: string) {
  const normalized = input.toLowerCase();

  return (
    input.includes('상세') ||
    input.includes('자세히') ||
    input.includes('전체') ||
    normalized.includes('detail') ||
    normalized.includes('details') ||
    normalized.includes('more')
  );
}

export function isAutomationRegistrationCountRequest(input: string) {
  const normalized = input.toLowerCase();
  const mentionsAutomation =
    input.includes('자동화') || normalized.includes('automation') || normalized.includes('plan');
  const mentionsRegistration =
    input.includes('등록') || input.includes('접수') || normalized.includes('register') || normalized.includes('count');
  const mentionsToday =
    input.includes('오늘') || normalized.includes('today');
  const asksCount =
    input.includes('총') ||
    input.includes('건수') ||
    input.includes('몇 건') ||
    input.includes('몇건') ||
    normalized.includes('count') ||
    normalized.includes('total');

  return mentionsAutomation && mentionsRegistration && (mentionsToday || asksCount);
}

function isAutomationRegistrationDefinitionRequest(input: string) {
  const normalized = input.toLowerCase();
  const mentionsAutomation = input.includes('자동화') || normalized.includes('automation');
  const mentionsRegistration = input.includes('등록') || input.includes('접수') || normalized.includes('register');
  const asksMeaning =
    input.includes('무슨 뜻') ||
    input.includes('뭐야') ||
    input.includes('뜻') ||
    input.includes('기준') ||
    input.includes('뭘 의미') ||
    normalized.includes('meaning') ||
    normalized.includes('define');

  return mentionsAutomation && mentionsRegistration && asksMeaning;
}

export function shouldUseAgenticCodexReply(input: string) {
  const normalized = input.toLowerCase();
  const trimmed = input.trim();

  if (isAutomationRegistrationCountRequest(input) || isAutomationRegistrationDefinitionRequest(input)) {
    return false;
  }

  if (!trimmed) {
    return false;
  }

  if (trimmed.length <= 3 && !/(db|api|fix|bug|log|sql)/i.test(trimmed)) {
    return false;
  }

  if (isDetailRequest(input) || isPlanDetailRequest(input) || isWorklogRequest(input)) {
    return false;
  }

  return (
    input.includes('수정') ||
    input.includes('변경') ||
    input.includes('구현') ||
    input.includes('추가') ||
    input.includes('삭제') ||
    input.includes('고쳐') ||
    input.includes('만들어') ||
    input.includes('등록해줘') ||
    input.includes('조회') ||
    input.includes('확인') ||
    input.includes('읽어') ||
    input.includes('분석') ||
    input.includes('DB') ||
    input.includes('API') ||
    input.includes('소스') ||
    input.includes('파일') ||
    input.includes('로그') ||
    /(?:\/|\\).+\.[a-z0-9]+/i.test(input) ||
    normalized.includes('fix') ||
    normalized.includes('implement') ||
    normalized.includes('change') ||
    normalized.includes('edit') ||
    normalized.includes('read') ||
    normalized.includes('source') ||
    normalized.includes('file') ||
    normalized.includes('db') ||
    normalized.includes('api') ||
    /\s/.test(trimmed)
  );
}

export function shouldUseTemplateMacroReply(context: ChatContext | null, input: string) {
  void context;
  void input;
  return false;
}

function summarizeCodexOutput(output: string) {
  const normalized = String(output ?? '').trim();

  if (!normalized) {
    return 'Codex 실행 결과가 비어 있습니다.';
  }

  const lines = normalized
    .split('\n')
    .map((line) => line.trimEnd())
    .filter(Boolean);

  return lines.slice(-12).join('\n');
}

function parseChatTokenMetricValue(valueText: string, suffixText?: string) {
  const normalizedValue = Number(String(valueText ?? '').replace(/,/g, '').trim());

  if (!Number.isFinite(normalizedValue)) {
    return null;
  }

  const suffix = String(suffixText ?? '').trim().toLowerCase();
  const multiplier = suffix === 'k' ? 1_000 : suffix === 'm' ? 1_000_000 : 1;
  return Math.max(0, Math.round(normalizedValue * multiplier));
}

function parseChatRequestTokenUsageMetrics(tokenUsageText: string) {
  const normalizedText = tokenUsageText
    .replace(/^tokens?\s+used\s*:?\s*/iu, '')
    .replace(/\(([^)]+)\)/g, ', $1')
    .trim();
  const metrics = new Map<string, number>();

  for (const match of normalizedText.matchAll(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)\s*(input|output|total|cached|reasoning)\b/giu)) {
    const label = match[3]?.toLowerCase() ?? '';
    const value = parseChatTokenMetricValue(match[1] ?? '', match[2]);

    if (!label || value === null) {
      continue;
    }

    metrics.set(label, value);
  }

  for (const match of normalizedText.matchAll(/\b(input|output|total|cached|reasoning)\s*[:=]?\s*(\d[\d,]*(?:\.\d+)?)\s*([km]?)/giu)) {
    const label = match[1]?.toLowerCase() ?? '';
    const value = parseChatTokenMetricValue(match[2] ?? '', match[3]);

    if (!label || value === null) {
      continue;
    }

    metrics.set(label, value);
  }

  if (metrics.size > 0) {
    return metrics;
  }

  const fallbackMatch = normalizedText.match(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)/i);

  if (!fallbackMatch) {
    return null;
  }

  const fallbackValue = parseChatTokenMetricValue(fallbackMatch[1], fallbackMatch[2]);

  if (fallbackValue === null) {
    return null;
  }

  return new Map([['total', fallbackValue]]);
}

function getChatRequestTotalTokenCount(metrics: Map<string, number>) {
  const total = metrics.get('total');

  if (typeof total === 'number' && Number.isFinite(total)) {
    return total;
  }

  return ['input', 'output', 'cached', 'reasoning'].reduce((sum, key) => sum + (metrics.get(key) ?? 0), 0);
}

function normalizeChatUsageMetricValue(value: unknown) {
  const normalizedValue =
    typeof value === 'string' ? Number(value.replace(/,/g, '').trim()) : Number(value);

  if (!Number.isFinite(normalizedValue)) {
    return null;
  }

  return Math.max(0, Math.round(normalizedValue));
}

function createChatUsageSnapshotFromMetrics(metrics: Map<string, number>) {
  const totalTokens = getChatRequestTotalTokenCount(metrics);

  return {
    tokenTotals: {
      total: metrics.get('total') ?? totalTokens,
      input: metrics.get('input') ?? 0,
      output: metrics.get('output') ?? 0,
      cached: metrics.get('cached') ?? 0,
      reasoning: metrics.get('reasoning') ?? 0,
    },
    totalTokens,
  } satisfies ChatConversationRequestUsageSnapshot;
}

function extractChatRequestUsageSnapshotFromStructuredJson(output: string): ChatConversationRequestUsageSnapshot | null {
  const lines = String(output ?? '')
    .split('\n')
    .map((line) => line.trim())
    .filter(Boolean);

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

    if (!line.startsWith('{')) {
      continue;
    }

    let parsed: Record<string, unknown>;

    try {
      parsed = JSON.parse(line) as Record<string, unknown>;
    } catch {
      continue;
    }

    const usage =
      parsed.usage && typeof parsed.usage === 'object'
        ? (parsed.usage as Record<string, unknown>)
        : parsed.response && typeof parsed.response === 'object' && parsed.response !== null &&
            'usage' in parsed.response &&
            parsed.response.usage &&
            typeof parsed.response.usage === 'object'
          ? (parsed.response.usage as Record<string, unknown>)
          : null;

    if (!usage) {
      continue;
    }

    const metrics = new Map<string, number>();
    const input = normalizeChatUsageMetricValue(usage.input_tokens);
    const output = normalizeChatUsageMetricValue(usage.output_tokens);
    const cached = normalizeChatUsageMetricValue(usage.cached_input_tokens);
    const reasoning = normalizeChatUsageMetricValue(usage.reasoning_output_tokens ?? usage.reasoning_tokens);
    const total =
      normalizeChatUsageMetricValue(usage.total_tokens) ??
      normalizeChatUsageMetricValue(usage.totalTokens);

    if (input !== null) {
      metrics.set('input', input);
    }

    if (output !== null) {
      metrics.set('output', output);
    }

    if (cached !== null) {
      metrics.set('cached', cached);
    }

    if (reasoning !== null) {
      metrics.set('reasoning', reasoning);
    }

    if (total !== null) {
      metrics.set('total', total);
    }

    if (metrics.size > 0) {
      return createChatUsageSnapshotFromMetrics(metrics);
    }
  }

  return null;
}

function extractChatRequestTokenUsageText(output: string) {
  const text = String(output ?? '');

  if (!text) {
    return '';
  }

  const lines = text.split('\n').map((line) => line.trim());
  const lastMatchedIndex = lines.reduce((foundIndex, line, index) => {
    return /tokens?\s+used/i.test(line) ? index : foundIndex;
  }, -1);

  if (lastMatchedIndex < 0) {
    return '';
  }

  const matchedLine = lines[lastMatchedIndex] ?? '';
  const usageLines = [matchedLine];

  if (!/\d/.test(matchedLine)) {
    for (let index = lastMatchedIndex + 1; index < lines.length && usageLines.length < 5; index += 1) {
      const nextLine = lines[index]?.trim() ?? '';

      if (!nextLine) {
        if (usageLines.length > 1) {
          break;
        }
        continue;
      }

      if (/^(?:\[plan-progress\]|done:|noop:|board_post:|recovered_commit:|git\s|diff --git|commit\s[0-9a-f]{7,})/i.test(nextLine)) {
        break;
      }

      if (!/\d/.test(nextLine) && !/(input|output|total|cached|reasoning)/i.test(nextLine)) {
        if (usageLines.length > 1) {
          break;
        }
        continue;
      }

      usageLines.push(nextLine);
    }
  }

  return usageLines.join(' ').replace(/\s+/g, ' ').trim();
}

function extractChatRequestUsageSnapshot(output: string): ChatConversationRequestUsageSnapshot | null {
  const structuredSnapshot = extractChatRequestUsageSnapshotFromStructuredJson(output);

  if (structuredSnapshot) {
    return structuredSnapshot;
  }

  const tokenUsageText = extractChatRequestTokenUsageText(output);

  if (!tokenUsageText) {
    return null;
  }

  const metrics = parseChatRequestTokenUsageMetrics(tokenUsageText);

  if (!metrics) {
    return null;
  }

  return createChatUsageSnapshotFromMetrics(metrics);
}

type ChatCodexReplyResult = {
  text: string;
  usageSnapshot: ChatConversationRequestUsageSnapshot | null;
  totalTokens: number | null;
};

class ChatRuntimeExecutionError extends Error {
  responseText: string;
  usageSnapshot: ChatConversationRequestUsageSnapshot | null;
  totalTokens: number | null;

  constructor(
    message: string,
    responseText = '',
    usageSnapshot: ChatConversationRequestUsageSnapshot | null = null,
    totalTokens: number | null = null,
  ) {
    super(message);
    this.name = 'ChatRuntimeExecutionError';
    this.responseText = responseText.trim();
    this.usageSnapshot = usageSnapshot;
    this.totalTokens = totalTokens;
  }
}

function summarizeCommand(command: string, limit = 180) {
  const normalized = String(command ?? '').replace(/\s+/g, ' ').trim();

  if (!normalized) {
    return '';
  }

  return normalized.length > limit ? `${normalized.slice(0, limit - 1).trimEnd()}...` : normalized;
}

function summarizeCommandOutput(output: string, maxLines = 3, maxLength = 220) {
  const lines = String(output ?? '')
    .replace(/\r/g, '')
    .split('\n')
    .map((line) => line.trim())
    .filter(Boolean);

  if (lines.length === 0) {
    return '';
  }

  const joined = lines.slice(0, maxLines).join(' / ');
  return joined.length > maxLength ? `${joined.slice(0, maxLength - 1).trimEnd()}...` : joined;
}

function inferCommandReason(command: string) {
  const normalized = command.toLowerCase();

  if (normalized.includes('rg ') || normalized.includes('ripgrep')) {
    return '관련 파일이나 텍스트를 빠르게 찾기 위해 검색했습니다.';
  }

  if (normalized.includes('sed -n') || normalized.includes('cat ') || normalized.includes('less ')) {
    return '해당 파일 내용을 직접 읽어 문맥을 확인했습니다.';
  }

  if (normalized.includes('ls ') || normalized === 'ls' || normalized.includes('rg --files')) {
    return '디렉터리 구조와 대상 파일 위치를 확인했습니다.';
  }

  if (normalized.includes('git status')) {
    return '작업 트리 상태와 변경 범위를 점검했습니다.';
  }

  if (normalized.includes('tsc ') || normalized.includes('npm test') || normalized.includes('node --test')) {
    return '수정 후 타입이나 동작 검증을 진행했습니다.';
  }

  if (normalized.includes('curl ') || normalized.includes('fetch')) {
    return '실제 응답이나 연결 상태를 확인했습니다.';
  }

  return '현재 요청을 해결하기 위한 다음 단계를 확인했습니다.';
}

function isIgnorableCodexDiagnosticLine(line: string) {
  const normalized = line.trim();

  if (!normalized) {
    return true;
  }

  return (
    /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\b/.test(normalized) ||
    normalized.includes('ignoring interface.defaultPrompt') ||
    normalized.includes('failed to open state db') ||
    normalized.includes('state db discrepancy') ||
    normalized.includes('Failed to kill MCP process group') ||
    normalized.includes('Failed to delete shell snapshot') ||
    normalized.includes('failed to unwatch ')
  );
}

function extractCodexActivityLog(parsed: Record<string, unknown>) {
  const type = typeof parsed.type === 'string' ? parsed.type : '';
  const item = parsed.item && typeof parsed.item === 'object' ? (parsed.item as Record<string, unknown>) : null;
  const itemType = typeof item?.type === 'string' ? item.type : '';

  if (!item || itemType !== 'command_execution') {
    return '';
  }

  const command = summarizeCommand(typeof item.command === 'string' ? item.command : '');

  if (!command) {
    return '';
  }

  const reason = inferCommandReason(command);

  if (type === 'item.started') {
    return `# 이유: ${reason}\n$ ${command}`;
  }

  if (type === 'item.completed') {
    const exitCode =
      typeof item.exit_code === 'number' && Number.isFinite(item.exit_code) ? Math.round(item.exit_code) : null;
    const outputSummary = summarizeCommandOutput(typeof item.aggregated_output === 'string' ? item.aggregated_output : '');
    const statusLabel = exitCode === null ? '# 결과: 완료' : exitCode === 0 ? '# 결과: 완료(0)' : `# 결과: 종료(${exitCode})`;

    return outputSummary ? `${statusLabel}\n# 출력: ${outputSummary}` : statusLabel;
  }

  return '';
}

function normalizeCodexReplyOutput(output: string) {
  const normalized = String(output ?? '').trim();
  return normalized || 'Codex 실행 결과가 비어 있습니다.';
}

function collectCodexTextFragments(value: unknown): string[] {
  if (typeof value === 'string') {
    const normalized = value.trim();
    return normalized ? [normalized] : [];
  }

  if (Array.isArray(value)) {
    return value.flatMap((item) => collectCodexTextFragments(item));
  }

  if (!value || typeof value !== 'object') {
    return [];
  }

  const record = value as Record<string, unknown>;
  const directTextKeys = ['text', 'delta', 'output_text', 'content', 'message'];

  for (const key of directTextKeys) {
    const fragments = collectCodexTextFragments(record[key]);

    if (fragments.length > 0) {
      return fragments;
    }
  }

  if (typeof record.type === 'string' && record.type.includes('output_text')) {
    const fragments = collectCodexTextFragments(record.text ?? record.delta);

    if (fragments.length > 0) {
      return fragments;
    }
  }

  return Object.values(record).flatMap((item) => collectCodexTextFragments(item));
}

function extractCompletedAgentMessageText(item: unknown) {
  if (!item || typeof item !== 'object') {
    return '';
  }

  const record = item as Record<string, unknown>;

  if (record.type !== 'agent_message') {
    return '';
  }

  return collectCodexTextFragments(record.text ?? record.content ?? record.message).join('');
}

export function extractCodexStreamText(parsed: Record<string, unknown>) {
  const type = typeof parsed.type === 'string' ? parsed.type : '';

  if (type === 'item.completed') {
    const completedText = extractCompletedAgentMessageText(parsed.item);
    return {
      type,
      completedText,
      deltaText: '',
    };
  }

  if (type === 'item.delta' || type === 'response.output_text.delta') {
    const deltaText =
      type === 'response.output_text.delta'
        ? collectCodexTextFragments(parsed.delta ?? parsed.text).join('')
        : collectCodexTextFragments(parsed.delta).join('');
    return {
      type,
      completedText: '',
      deltaText,
    };
  }

  if (type === 'response.completed') {
    const completedText = collectCodexTextFragments(parsed.response).join('');
    return {
      type,
      completedText,
      deltaText: '',
    };
  }

  return {
    type,
    completedText: '',
    deltaText: '',
  };
}

export function parseStructuredCodexStdoutLine(line: string) {
  const normalizedLine = String(line ?? '').trim();

  if (!normalizedLine) {
    return {
      activityLog: '',
      completedText: '',
      deltaText: '',
      usageSnapshot: null,
      shouldKeepRaw: false,
    };
  }

  let parsed: Record<string, unknown>;

  try {
    parsed = JSON.parse(normalizedLine) as Record<string, unknown>;
  } catch {
    return {
      activityLog: '',
      completedText: '',
      deltaText: '',
      usageSnapshot: null,
      shouldKeepRaw: true,
    };
  }

  const activityLog = extractCodexActivityLog(parsed);
  const streamText = extractCodexStreamText(parsed);
  const usageSnapshot = extractChatRequestUsageSnapshotFromStructuredJson(normalizedLine);
  const shouldKeepRaw = !activityLog && !streamText.completedText && !streamText.deltaText;

  return {
    activityLog,
    completedText: streamText.completedText,
    deltaText: streamText.deltaText,
    usageSnapshot,
    shouldKeepRaw,
  };
}

async function streamReplyChunks(text: string, onProgress?: (text: string) => void, chunkSize = 28, delayMs = 24) {
  const normalized = normalizeCodexReplyOutput(text);

  if (!onProgress) {
    return normalized;
  }

  for (let index = chunkSize; index < normalized.length; index += chunkSize) {
    onProgress(normalized.slice(0, index));
    await new Promise((resolve) => {
      setTimeout(resolve, delayMs);
    });
  }

  onProgress(normalized);
  return normalized;
}

function escapeRegExp(value: string) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function stripTrailingLineInfo(value: string) {
  return value.replace(/:\d+(?::\d+)?$/, '');
}

function trimPathCandidate(value: string) {
  return value.replace(/^[("'`\[]+/, '').replace(/[)\]"'`,.;:]+$/, '');
}

function encodeUrlPathSegments(value: string) {
  return value
    .split('/')
    .filter(Boolean)
    .map((segment) => encodeURIComponent(segment))
    .join('/');
}

const CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN =
  /^(?:src|public|docs|etc|scripts|\.github)\/[^\n\s)\]"'`,]+$/;
const CHAT_RESOURCE_ROOT_FILE_PATTERN =
  /^(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)$/;

function isLikelyRepoRelativeChatResourcePath(candidate: string) {
  const normalized = candidate.replace(/^\/+/, '');
  return (
    CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN.test(normalized) ||
    CHAT_RESOURCE_ROOT_FILE_PATTERN.test(normalized)
  );
}

function buildChatResourcePublicUrl(relativePath: string) {
  const cleaned = relativePath.replace(/^public\//, '').replace(/^\/+/, '');
  return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${encodeUrlPathSegments(cleaned)}`;
}

function normalizeEmbeddedChatResourceUrls(text: string) {
  return String(text ?? '')
    .replace(/https?:\/(api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/gi, '/$1')
    .replace(
      /(?:\/[^\s)\]"'`,]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/g,
      (_match, resourcePath) => resourcePath,
    );
}

function toPosixPath(value: string) {
  return value.split(path.sep).join('/');
}

function normalizeExistingChatPublicUrl(candidate: string) {
  const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()))
    .replace(/^\/+/, '')
    .replace(/^public\//, '');

  if (!cleaned.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) {
    return null;
  }

  return buildChatResourcePublicUrl(cleaned);
}

export function extractDiffCodeBlocks(output: string) {
  const matches = Array.from(String(output ?? '').matchAll(/```diff[^\n]*\n([\s\S]*?)\n```/g));

  return matches
    .map((match) => (typeof match[1] === 'string' ? match[1].trim() : ''))
    .filter(Boolean);
}

function protectDiffCodeBlocks(output: string) {
  const blocks: string[] = [];
  const text = String(output ?? '').replace(/```diff[^\n]*\n[\s\S]*?\n```/g, (match) => {
    const token = `__CODEX_DIFF_BLOCK_${blocks.length}__`;
    blocks.push(match);
    return token;
  });

  return { text, blocks };
}

function restoreDiffCodeBlocks(output: string, blocks: string[]) {
  return blocks.reduce(
    (current, block, index) => current.replace(`__CODEX_DIFF_BLOCK_${index}__`, block),
    String(output ?? ''),
  );
}

async function resolveChatResourceSourcePath(repoPath: string, candidate: string) {
  const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()));

  if (!cleaned) {
    return null;
  }

  let sourcePath: string | null = null;

  if (cleaned.startsWith(repoPath)) {
    sourcePath = cleaned;
  } else if (cleaned.startsWith('/')) {
    if (isLikelyRepoRelativeChatResourcePath(cleaned)) {
      sourcePath = path.resolve(repoPath, cleaned.replace(/^\/+/, ''));
    } else {
      sourcePath = cleaned;
    }
  } else {
    sourcePath = path.resolve(repoPath, cleaned);
  }

  const normalizedRepoPath = path.resolve(repoPath);
  const normalizedSourcePath = path.resolve(sourcePath);
  const relativePath = path.relative(normalizedRepoPath, normalizedSourcePath);

  if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
    return null;
  }

  try {
    const sourceStat = await stat(normalizedSourcePath);

    if (!sourceStat.isFile()) {
      return null;
    }
  } catch {
    return null;
  }

  return {
    absolutePath: normalizedSourcePath,
    relativePath: toPosixPath(relativePath),
  };
}

export function isChatResourcePathCandidate(candidate: string) {
  const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()));

  if (!cleaned) {
    return false;
  }

  if (cleaned.startsWith(`/${CHAT_PUBLIC_RESOURCE_DIR}/`)) {
    return true;
  }

  return isLikelyRepoRelativeChatResourcePath(cleaned) || /^\/(?:workspace|root|home|Users|tmp)\//.test(cleaned);
}

export async function stageChatResourceFile(repoPath: string, sessionId: string, candidate: string) {
  const existingPublicUrl = normalizeExistingChatPublicUrl(candidate);

  if (existingPublicUrl) {
    return existingPublicUrl;
  }

  const resolvedSource = await resolveChatResourceSourcePath(repoPath, candidate);

  if (!resolvedSource) {
    return null;
  }

  await ensureChatSessionResourceDirectories(repoPath, sessionId);
  const targetRelativePath = `${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/${resolvedSource.relativePath}`;
  const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath);
  await mkdir(path.dirname(targetAbsolutePath), { recursive: true });
  await cp(resolvedSource.absolutePath, targetAbsolutePath, { force: true });

  return buildChatResourcePublicUrl(targetRelativePath);
}

async function stageChatDiffResourceFiles(repoPath: string, sessionId: string, output: string) {
  const diffBlocks = extractDiffCodeBlocks(output);

  if (diffBlocks.length === 0) {
    return [];
  }

  const urls: string[] = [];
  await ensureChatSessionResourceDirectories(repoPath, sessionId);

  for (const [index, diffText] of diffBlocks.entries()) {
    const fileName = diffBlocks.length === 1 ? 'response.diff' : `response-${index + 1}.diff`;
    const targetRelativePath =
      `${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/_generated/${fileName}`;
    const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath);
    await mkdir(path.dirname(targetAbsolutePath), { recursive: true });
    await writeFile(targetAbsolutePath, `${diffText}\n`, 'utf8');
    urls.push(buildChatResourcePublicUrl(targetRelativePath));
  }

  return urls;
}

async function ensureWorldWritableDirectory(absolutePath: string) {
  await mkdir(absolutePath, { recursive: true, mode: CHAT_SESSION_RESOURCE_DIR_MODE });
  await chmod(absolutePath, CHAT_SESSION_RESOURCE_DIR_MODE).catch(() => {});
}

export async function ensureChatSessionResourceDirectories(repoPath: string, sessionId: string) {
  const sessionRoot = path.join(repoPath, 'public', CHAT_PUBLIC_RESOURCE_DIR, sessionId);
  const resourceRoot = path.join(sessionRoot, CHAT_PUBLIC_RESOURCE_SUBDIR);
  const sourceRoot = path.join(resourceRoot, 'source');
  const uploadRoot = path.join(resourceRoot, 'uploads');

  await ensureWorldWritableDirectory(sessionRoot);
  await ensureWorldWritableDirectory(resourceRoot);
  await ensureWorldWritableDirectory(sourceRoot);
  await ensureWorldWritableDirectory(uploadRoot);

  return {
    sessionRoot,
    resourceRoot,
    sourceRoot,
    uploadRoot,
  };
}

function appendDiffResourceLinks(output: string, diffUrls: string[]) {
  if (diffUrls.length === 0) {
    return output;
  }

  const uniqueUrls = diffUrls.filter((url, index) => diffUrls.indexOf(url) === index && !output.includes(url));

  if (uniqueUrls.length === 0) {
    return output;
  }

  const hiddenPreviewTags = uniqueUrls.map((url) => `[[preview:${url}]]`).join('\n');
  return `${output}\n\n${hiddenPreviewTags}`;
}

function isPreviewEligibleChatResourceUrl(url: string) {
  const pathname = url.split('?')[0]?.toLowerCase() ?? '';
  return /\.(?:png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|pdf|html?|diff)$/i.test(pathname);
}

function resolveChatResourcePublicUrlToAbsolutePath(repoPath: string, url: string) {
  const normalizedUrl = normalizeEmbeddedChatResourceUrls(url).trim();
  const relativeUrl = normalizedUrl.replace(/^\/+/, '');
  const routePrefix = `${CHAT_API_RESOURCE_ROUTE_PREFIX.replace(/^\/+/, '')}/`;

  if (!relativeUrl.startsWith(routePrefix)) {
    return null;
  }

  const encodedResourcePath = relativeUrl.slice(routePrefix.length);

  try {
    const decodedPath = encodedResourcePath
      .split('/')
      .filter(Boolean)
      .map((segment) => decodeURIComponent(segment))
      .join('/');

    if (!decodedPath.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) {
      return null;
    }

    return path.resolve(repoPath, 'public', decodedPath);
  } catch {
    return null;
  }
}

async function doesChatResourceUrlExist(repoPath: string, url: string) {
  const absolutePath = resolveChatResourcePublicUrlToAbsolutePath(repoPath, url);

  if (!absolutePath) {
    return false;
  }

  try {
    const fileStat = await stat(absolutePath);
    return fileStat.isFile();
  } catch {
    return false;
  }
}

async function sanitizeChatResourcePresentation(output: string, repoPath: string) {
  let sanitized = normalizeEmbeddedChatResourceUrls(output);
  const previewMatches = Array.from(sanitized.matchAll(/\[\[preview:([^\]\n]+)\]\]/gi));
  const previewReplacementMap = new Map<string, string>();

  for (const match of previewMatches) {
    const marker = match[0] ?? '';
    const rawUrl = match[1]?.trim() ?? '';

    if (!marker || previewReplacementMap.has(marker)) {
      continue;
    }

    const normalizedUrl = normalizeEmbeddedChatResourceUrls(rawUrl);
    const exists = await doesChatResourceUrlExist(repoPath, normalizedUrl);
    const keepMarker = exists && isPreviewEligibleChatResourceUrl(normalizedUrl);
    previewReplacementMap.set(marker, keepMarker ? `[[preview:${normalizedUrl}]]` : '');
  }

  for (const [source, target] of previewReplacementMap.entries()) {
    sanitized = sanitized.replaceAll(source, target);
  }

  return sanitized.replace(/\n{3,}/g, '\n\n').trim();
}

export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) {
  const { text: outputWithoutDiffBlocks, blocks: diffBlocks } = protectDiffCodeBlocks(output);
  const escapedRepoPath = escapeRegExp(path.resolve(repoPath));
  const filePathPattern = "[^\\n\\s)\\]\"'`,]+";
  const rootFilePattern = String.raw`\/?(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)`;
  const candidatePattern = new RegExp(
    `${escapedRepoPath}\\/${filePathPattern}|(?:\\/?(?:public\\/)?\\.codex_chat|src|public|docs|etc|scripts)\\/${filePathPattern}|${rootFilePattern}`,
    'g',
  );
  const matches = [...outputWithoutDiffBlocks.matchAll(candidatePattern)];
  let rewrittenOutput = outputWithoutDiffBlocks;
  if (matches.length > 0) {
    const replacementMap = new Map<string, string>();

    for (const match of matches) {
      const rawCandidate = match[0]?.trim();

      if (!rawCandidate || replacementMap.has(rawCandidate)) {
        continue;
      }

      const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate);

      if (stagedUrl) {
        replacementMap.set(rawCandidate, stagedUrl);
      }
    }

    const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length);

    for (const [sourcePath, publicUrl] of replacements) {
      rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl);
    }
  }

  rewrittenOutput = await sanitizeChatResourcePresentation(rewrittenOutput, repoPath);
  rewrittenOutput = restoreDiffCodeBlocks(rewrittenOutput, diffBlocks);

  const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, output);
  return sanitizeChatResourcePresentation(appendDiffResourceLinks(rewrittenOutput, diffUrls), repoPath);
}

function normalizeChatPromptHistoryText(text: string) {
  return String(text ?? '').replace(/\s+/g, ' ').trim();
}

function normalizePromptHistoryMessageLimit(value: number | undefined) {
  if (value === undefined || !Number.isFinite(value)) {
    return CHAT_PROMPT_HISTORY_MAX_MESSAGES;
  }

  return Math.min(50, Math.max(1, Math.round(value)));
}

function normalizePromptHistoryCharLimit(value: number | undefined) {
  if (value === undefined || !Number.isFinite(value)) {
    return CHAT_PROMPT_HISTORY_MAX_CHARS;
  }

  return Math.min(20_000, Math.max(500, Math.round(value)));
}

async function buildRecentChatPromptHistory(
  sessionId: string,
  requestId: string,
  limits?: {
    maxMessages?: number;
    maxChars?: number;
    disabled?: boolean;
  },
) {
  if (limits?.disabled) {
    return {
      items: [],
      omittedCount: 0,
    };
  }

  const maxMessages = normalizePromptHistoryMessageLimit(limits?.maxMessages);
  const maxChars = normalizePromptHistoryCharLimit(limits?.maxChars);
  const messages = await listChatConversationMessages(sessionId, { limit: 80 });
  const relevantMessages = messages.filter((message: (typeof messages)[number]) => {
    if (message.clientRequestId?.trim() === requestId.trim()) {
      return false;
    }

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

    return Boolean(normalizeChatPromptHistoryText(message.text));
  });

  const selectedMessages: typeof relevantMessages = [];
  let totalChars = 0;

  for (let index = relevantMessages.length - 1; index >= 0; index -= 1) {
    const message = relevantMessages[index];
    const text = normalizeChatPromptHistoryText(message.text);
    const nextChars = text.length;

    if (
      selectedMessages.length >= maxMessages ||
      (selectedMessages.length > 0 && totalChars + nextChars > maxChars)
    ) {
      break;
    }

    selectedMessages.unshift(message);
    totalChars += nextChars;
  }

  return {
    items: selectedMessages.map(
      (message: (typeof selectedMessages)[number]) => `[${message.author}] ${normalizeChatPromptHistoryText(message.text)}`,
    ),
    omittedCount: Math.max(0, relevantMessages.length - selectedMessages.length),
  };
}

function cloneChatContext(context: ChatContext | null): ChatContext | null {
  return context ? { ...context } : null;
}

function resolvePromptChatTypeLabel(context: ChatContext | null) {
  return context?.chatTypeLabel?.trim() || '';
}

function normalizeChatContextIdList(ids?: string[] | null) {
  if (!Array.isArray(ids)) {
    return [];
  }

  return Array.from(
    new Set(
      ids
        .map((id) => id?.trim() || '')
        .filter(Boolean),
    ),
  );
}

function normalizeChatContextEntries(
  entries: ChatContext['defaultContexts'],
): Array<{ id: string; title: string; content: string }> {
  if (!Array.isArray(entries)) {
    return [];
  }

  return entries
    .map((entry) => ({
      id: entry?.id?.trim() || '',
      title: entry?.title?.trim() || '',
      content: entry?.content?.trim() || '',
    }))
    .filter((entry) => entry.content);
}

function normalizePromptContextText(value: string | null | undefined, maxLength = 400) {
  const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();

  if (!normalized) {
    return '';
  }

  if (normalized.length <= maxLength) {
    return normalized;
  }

  return `${normalized.slice(0, maxLength).trimEnd()}...`;
}

function normalizeChatPromptContextRef(
  value: ChatPromptContextRef | null | undefined,
): ChatPromptContextRef | null {
  if (!value || value.key !== 'prompt_parent_question') {
    return null;
  }

  const promptTitle = normalizePromptContextText(value.promptTitle, 500);

  if (!promptTitle) {
    return null;
  }

  return {
    key: 'prompt_parent_question',
    promptTitle,
    promptDescription: normalizePromptContextText(value.promptDescription, 1000) || null,
    parentQuestionText: normalizePromptContextText(value.parentQuestionText, 1000) || null,
  };
}

function buildPromptContextInstructionLines(promptContextRef?: ChatPromptContextRef | null) {
  if (!promptContextRef || promptContextRef.key !== 'prompt_parent_question') {
    return [];
  }

  const lines = ['prompt 문맥 참조:'];

  if (promptContextRef.parentQuestionText) {
    lines.push(`- 상위 사용자 질의: ${promptContextRef.parentQuestionText}`);
  }

  lines.push(`- 대상 질의: ${promptContextRef.promptTitle}`);

  if (promptContextRef.promptDescription) {
    lines.push(`- 질의 설명: ${promptContextRef.promptDescription}`);
  }

  lines.push('- 위 항목은 말풍선 원문이 아니라 서버가 전달한 참조 문맥입니다. 현재 요청을 해석할 때 함께 참고하세요.');
  return lines;
}

function normalizeCodexParticipants(
  participants: ChatContext['codexParticipants'],
): Array<{
  id: string;
  name: string;
  model: string;
  prompt: string;
  chatTypeId: string | null;
  defaultContextIds: string[];
  role: CodexParticipantRole;
}> {
  if (!Array.isArray(participants)) {
    return [];
  }

  return Array.from(
    new Map(
      participants
        .map((participant, index) => {
          const name = participant?.name?.trim() || '';
          const model = participant?.model?.trim() || '';
          const prompt = participant?.prompt?.trim() || '';
          const chatTypeId = participant?.chatTypeId?.trim() || null;
          const defaultContextIds = normalizeChatContextIdList(participant?.defaultContextIds);
          const role =
            participant?.role === 'moderator'
              ? 'moderator'
              : participant?.role === 'conversation'
                ? 'conversation'
                : participant?.role === 'reviewer'
                  ? 'reviewer'
                : 'default';
          const id = participant?.id?.trim() || `codex-participant-${index + 1}`;

          if (!name || !model) {
            return null;
          }

          return [
            id,
            {
              id,
              name,
              model,
              prompt,
              chatTypeId,
              defaultContextIds,
              role,
            },
          ] as const;
        })
        .filter(
          (
            item,
          ): item is readonly [
            string,
            {
              id: string;
              name: string;
              model: string;
              prompt: string;
              chatTypeId: string | null;
              defaultContextIds: string[];
              role: CodexParticipantRole;
            },
          ] => Boolean(item),
        ),
    ).values(),
  );
}

function buildStructuredChatContextSections(context: ChatContext | null) {
  const sections: string[] = [];
  const baseDescription = context?.chatTypeBaseDescription?.trim() || '';
  const defaultContexts = normalizeChatContextEntries(context?.defaultContexts);
  const customContextTitle = context?.customContextTitle?.trim() || '';
  const customContextContent = context?.customContextContent?.trim() || '';

  if (baseDescription) {
    sections.push(['## 채팅 유형 context 원문', baseDescription].join('\n'));
  }

  if (defaultContexts.length > 0) {
    sections.push(
      [
        '## 채팅방에서 선택한 공통 문맥',
        '- 아래 공통 문맥은 채팅 유형 context와 충돌하지 않는 범위에서만 보조로 적용하세요.',
        ...defaultContexts.map((entry) =>
          [`### ${entry.title || '공통 문맥'}`, entry.content].filter(Boolean).join('\n'),
        ),
      ].join('\n\n'),
    );
  }

  if (customContextTitle || customContextContent) {
    sections.push(
      [
        `## 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`,
        '- 아래 채팅방 전용 메모는 채팅 유형 context를 바꾸지 않으며, 충돌하지 않는 범위에서만 보조로 해석하세요.',
        customContextContent,
      ]
        .filter(Boolean)
        .join('\n'),
    );
  }

  return sections;
}

function resolveChatTypeExecutionPolicy(context: ChatContext | null) {
  return context?.chatTypeExecutionPolicy ?? createDefaultChatTypeExecutionPolicy();
}

function buildChatSessionReferenceContextSummary(context: ChatContext | null) {
  const lines: string[] = [];
  const defaultContexts = normalizeChatContextEntries(context?.defaultContexts);
  const customContextTitle = context?.customContextTitle?.trim() || '';
  const customContextContent = context?.customContextContent?.trim() || '';

  lines.push('## 현재 채팅 유형 context 요약');
  lines.push('- 이 문서에는 채팅방 전용 메모와 현재 적용 중인 context 식별 정보만 짧게 유지합니다.');
  lines.push('- 채팅 유형 context가 최상위이며, 공통 문맥과 채팅방 전용 메모는 그 아래 보조 문맥으로만 사용합니다.');
  lines.push('- 공통 문맥 상세 원문은 공통 문맥 관리 데이터와 실행 prompt 본문을 기준으로 확인합니다.');

  if (defaultContexts.length > 0) {
    lines.push('');
    lines.push('### 적용 중인 공통 문맥');
    lines.push(...defaultContexts.map((entry) => `- ${entry.title || entry.id || '공통 문맥'}`));
  }

  if (customContextTitle || customContextContent) {
    lines.push('');
    lines.push(`### 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`);
    lines.push(customContextContent || '- 전용 메모 본문 없음');
  }

  return lines.join('\n');
}

function composeResolvedChatTypeDescription(
  baseDescription: string,
  defaultContexts: Array<{ id: string; title: string; content: string }>,
  customContextTitle?: string | null,
  customContextContent?: string | null,
) {
  const sections = [baseDescription.trim()].filter(Boolean);

  defaultContexts.forEach((context) => {
    const normalizedContent = context.content.trim();

    if (!normalizedContent) {
      return;
    }

    sections.push(`## 기본 유형 · ${context.title || '공통 문맥'}\n${normalizedContent}`);
  });

  const normalizedCustomContextTitle = customContextTitle?.trim() || '';
  const normalizedCustomContextContent = customContextContent?.trim() || '';

  if (normalizedCustomContextTitle || normalizedCustomContextContent) {
    sections.push(
      [`## 채팅방 전용 Context${normalizedCustomContextTitle ? ` · ${normalizedCustomContextTitle}` : ''}`, normalizedCustomContextContent]
        .filter(Boolean)
        .join('\n'),
    );
  }

  return sections.join('\n\n').trim();
}

export async function resolveCodexLiveChatContext(context: ChatContext | null, sessionId?: string | null) {
  if (!context) {
    return null;
  }

  const normalizedChatTypeId = context.chatTypeId?.trim() || null;

  if (!normalizedChatTypeId) {
    return cloneChatContext(context);
  }

  const [chatTypesConfig, chatContextSettings] = await Promise.all([
    getChatTypesConfig(),
    getChatContextSettingsConfig(),
  ]);
  const resolvedChatType =
    chatTypesConfig.chatTypes.find((item) => item.id === normalizedChatTypeId && item.enabled) ?? null;
  const roomContext =
    chatContextSettings.roomContexts.find((item) => item.sessionId === (sessionId?.trim() || '')) ?? null;
  const explicitDefaultContextIds = normalizeChatContextIdList(context.defaultContextIds);
  const chatTypeDefaultContextIds =
    chatContextSettings.chatTypeDefaults.find((item) => item.chatTypeId === normalizedChatTypeId)?.defaultContextIds ?? [];
  const resolvedDefaultContextIds =
    explicitDefaultContextIds.length > 0
      ? explicitDefaultContextIds
      : roomContext?.defaultContextIds.length
        ? roomContext.defaultContextIds
        : chatTypeDefaultContextIds;
  const resolvedDefaultContexts = resolvedDefaultContextIds
    .map((contextId) =>
      chatContextSettings.defaultContexts.find((item) => item.id === contextId && item.enabled),
    )
    .filter((item): item is NonNullable<typeof item> => Boolean(item))
    .map((item) => ({
      id: item.id,
      title: item.title,
      content: item.content,
    }));
  const resolvedCustomContextTitle = roomContext?.customContextTitle?.trim() || '';
  const resolvedCustomContextContent = roomContext?.customContextContent?.trim() || '';
  const resolvedCodexParticipants = normalizeCodexParticipants(roomContext?.codexParticipants ?? context.codexParticipants);
  const resolvedBaseDescription = resolvedChatType?.description?.trim() || context.chatTypeBaseDescription?.trim() || '';
  const resolvedDescription =
    composeResolvedChatTypeDescription(
      resolvedBaseDescription,
      resolvedDefaultContexts,
      resolvedCustomContextTitle,
      resolvedCustomContextContent,
    ) ||
    context.chatTypeDescription?.trim() ||
    resolvedBaseDescription;

  return {
    ...context,
    chatTypeId: normalizedChatTypeId,
    chatTypeLabel: resolvedChatType?.name ?? context.chatTypeLabel ?? '',
    chatTypeDescription: resolvedDescription,
    chatTypeBaseDescription: resolvedBaseDescription,
    chatTypeExecutionPolicy:
      resolvedChatType?.executionPolicy ?? context.chatTypeExecutionPolicy ?? createDefaultChatTypeExecutionPolicy(),
    codexModel: (resolvedCodexParticipants[0]?.model ?? context.codexModel?.trim()) || null,
    codexParticipants: resolvedCodexParticipants,
    defaultContextIds: resolvedDefaultContextIds,
    defaultContexts: resolvedDefaultContexts,
    customContextTitle: resolvedCustomContextTitle || null,
    customContextContent: resolvedCustomContextContent || null,
  } satisfies ChatContext;
}

function buildChatSessionReferenceAutoSection(args: {
  context: ChatContext | null;
  sessionId: string;
  requestId: string;
  requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled';
  requestedAt?: Date | null;
  completedAt?: Date | null;
  input?: string;
}) {
  const chatTypeLabel = resolvePromptChatTypeLabel(args.context) || '없음';
  const pageTitle = args.context?.pageTitle?.trim() || '없음';
  const topMenu = args.context?.topMenu?.trim() || '없음';
  const pageUrl = args.context?.pageUrl?.trim() || '없음';
  const focusedComponentId = args.context?.focusedComponentId?.trim() || '없음';
  const codexModel = args.context?.codexModel?.trim() || '기본값';
  const codexParticipants = normalizeCodexParticipants(args.context?.codexParticipants);
  const requestStatusLabel =
    args.requestStatus === 'completed'
      ? '완료'
      : args.requestStatus === 'failed'
        ? '실패'
        : args.requestStatus === 'cancelled'
          ? '취소'
          : '실행 중';

  return [
    CHAT_SESSION_REFERENCE_AUTO_START,
    '## 자동 갱신 문맥',
    `- 마지막 갱신 시각: ${formatTime(new Date())}`,
    `- sessionId: ${args.sessionId}`,
    `- requestId: ${args.requestId}`,
    `- 요청 상태: ${requestStatusLabel}`,
    `- 요청 시작 시각: ${formatTime(args.requestedAt ?? new Date())}`,
    ...(args.completedAt ? [`- 요청 종료 시각: ${formatTime(args.completedAt)}`] : []),
    `- 채팅 유형: ${chatTypeLabel}`,
    `- Codex 모델: ${codexModel}`,
    ...(codexParticipants.length > 0
      ? [
          `- Codex 참가자: ${codexParticipants
            .map((participant) =>
              `${participant.name}(${participant.model}${participant.role !== 'default' ? `, 역할:${participant.role}` : ''}${participant.chatTypeId ? `, 유형:${participant.chatTypeId}` : ''})`,
            )
            .join(', ')}`,
        ]
      : []),
    `- 화면 제목: ${pageTitle}`,
    `- topMenu: ${topMenu}`,
    `- focusedComponentId: ${focusedComponentId}`,
    `- pageUrl: ${pageUrl}`,
    '',
    buildChatSessionReferenceContextSummary(args.context),
    CHAT_SESSION_REFERENCE_AUTO_END,
  ].join('\n');
}

function mergeChatSessionReferenceContent(existingContent: string, autoSection: string) {
  const trimmedExisting = existingContent.trim();
  const defaultHeader = [
    '# 채팅방 참고 리소스',
    '',
    '이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
    '사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.',
  ].join('\n');

  if (!trimmedExisting) {
    return `${defaultHeader}\n\n${autoSection}\n`;
  }

  const firstAutoStartIndex = existingContent.indexOf(CHAT_SESSION_REFERENCE_AUTO_START);

  if (firstAutoStartIndex >= 0) {
    const preservedHeader = existingContent.slice(0, firstAutoStartIndex).trim() || defaultHeader;
    return `${preservedHeader}\n\n${autoSection}\n`;
  }

  return `${trimmedExisting || defaultHeader}\n\n${autoSection}\n`;
}

async function writeChatSessionReferenceContentSafely(absolutePath: string, content: string) {
  try {
    await writeFile(absolutePath, content, 'utf8');
    return;
  } catch (error) {
    if (
      !error ||
      typeof error !== 'object' ||
      !('code' in error) ||
      (error.code !== 'EACCES' && error.code !== 'EPERM')
    ) {
      throw error;
    }
  }

  await rm(absolutePath, { force: true });
  await writeFile(absolutePath, content, 'utf8');
}

export async function ensureChatSessionReferenceResource(args: {
  repoPath: string;
  sessionId: string;
  requestId: string;
  context: ChatContext | null;
  requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled';
  requestedAt?: Date | null;
  completedAt?: Date | null;
  input: string;
  recentHistoryLines: string[];
  omittedHistoryCount: number;
}) {
  const ensuredDirectories = await ensureChatSessionResourceDirectories(args.repoPath, args.sessionId);
  const resourceRelativePath = `public/.codex_chat/${args.sessionId}/resource/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`;
  const absolutePath = path.join(ensuredDirectories.sourceRoot, 'chat-room-reference.md');
  const autoSection = buildChatSessionReferenceAutoSection({
    context: args.context,
    sessionId: args.sessionId,
    requestId: args.requestId,
    requestStatus: args.requestStatus,
    requestedAt: args.requestedAt,
    completedAt: args.completedAt,
    input: args.input,
  });

  let existingContent = '';

  try {
    existingContent = await readFile(absolutePath, 'utf8');
  } catch {
    existingContent = '';
  }

  const nextContent = mergeChatSessionReferenceContent(existingContent, autoSection);

  if (nextContent !== existingContent) {
    await writeChatSessionReferenceContentSafely(absolutePath, nextContent);
  }

  return resourceRelativePath;
}

async function refreshChatSessionReferenceForRequest(args: {
  sessionId: string;
  requestId: string;
  context: ChatContext | null;
  input: string;
  requestStatus: 'started' | 'completed' | 'failed' | 'cancelled';
  requestedAt: Date;
  completedAt?: Date | null;
}) {
  const repoPath = resolveMainProjectRoot();
  await ensureChatSessionReferenceResource({
    repoPath,
    sessionId: args.sessionId,
    requestId: args.requestId,
    context: args.context,
    requestStatus: args.requestStatus,
    requestedAt: args.requestedAt,
    completedAt: args.completedAt ?? null,
    input: args.input,
    recentHistoryLines: [],
    omittedHistoryCount: 0,
  });
}

function buildChatTypeInstructionBlock(context: ChatContext | null) {
  const chatTypeLabel = resolvePromptChatTypeLabel(context);
  const chatTypeDescription = context?.chatTypeDescription?.trim() || '';
  const structuredSections = buildStructuredChatContextSections(context);
  const hasSpecificChatType = Boolean(chatTypeLabel);
  const hasContextDescription = Boolean(
    structuredSections.length > 0 || (chatTypeDescription && chatTypeDescription !== '없음'),
  );

  if (!hasSpecificChatType && !hasContextDescription) {
    return [
      '## 채팅 유형 context 필수 규칙',
      '- 선택된 채팅 유형 context가 없습니다.',
      '- 그래도 AGENTS.md와 현재 사용자 요청을 기준으로 처리하되, Plan 자동화용 자동화 유형 context는 섞지 마세요.',
    ];
  }

  return [
    '## 채팅 유형 context 필수 규칙',
    '- 아래 채팅 유형 context는 선택 사항이나 참고 메모가 아니라 이 Codex Live 실행의 상위 필수 지시입니다.',
    '- 우선순위는 1. 채팅 유형 context 2. 현재 턴의 직접 사용자 지시 3. 채팅방에서 선택한 공통 문맥과 전용 메모 4. 최근 대화 문맥과 화면 문맥 순서로 해석하세요.',
    '- 사용자 요청, 공통 문맥, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선하세요.',
    '- context 안의 작업 범위, 금지사항, 검증 방식, 답변 스타일, 산출물 규칙을 반드시 지키세요.',
    '- 공통 문맥과 채팅방 전용 메모는 채팅 유형 context를 덮어쓰지 못하며, 충돌하지 않는 범위에서만 보조로 적용하세요.',
    '- context가 모호하면 무시하지 말고, 가장 보수적으로 해석해 지킬 수 있는 범위에서 처리하세요.',
    '- 실행 전 내부적으로 context 준수 여부를 점검하고, 최종 답변도 context 기준에 맞추세요.',
    '',
    '### 선택된 채팅 유형',
    `- label: ${chatTypeLabel || '없음'}`,
    '',
    '### 반드시 지킬 context 원문',
    structuredSections.length > 0
      ? structuredSections.join('\n\n')
      : (chatTypeDescription || '선택된 채팅 유형 context 원문 없음'),
  ];
}

function buildChatSessionReferenceInstructionBlock(referenceContent?: string | null) {
  const normalizedContent = referenceContent?.trim() || '';

  if (!normalizedContent) {
    return [
      '## 채팅방 참고 문서',
      '- 현재 채팅방 참고 문서 본문을 불러오지 못했습니다.',
      '- 그래도 위에 제공된 참고 문서 경로를 우선 확인하고, 채팅 유형 context와 AGENTS.md 규칙을 먼저 따르세요.',
    ];
  }

  return [
    '## 채팅방 참고 문서',
    '- 아래는 이 채팅방에서 시작 전에 먼저 읽어야 하는 참고 문서 원문입니다.',
    '- 이 문서의 지시와 메모를 현재 요청 해석에 즉시 반영하세요.',
    '',
    normalizedContent,
  ];
}

function buildReferencedRequestInstructionBlock(referencedRequest?: ChatConversationRequestItem | null) {
  if (!referencedRequest) {
    return [];
  }

  const requestId = referencedRequest.requestId.trim();
  const referencedQuestion = normalizeChatPromptHistoryText(referencedRequest.userText || '');
  const referencedAnswer = normalizeChatPromptHistoryText(
    referencedRequest.responseText || referencedRequest.statusMessage || '',
  );

  return [
    '## 답변 참조',
    '- 현재 요청은 아래 이전 답변을 명시적으로 선택해 이어가는 후속 요청입니다.',
    '- 최근 대화 일반 문맥보다 아래 참조 답변을 우선해서 해석하세요.',
    requestId ? `- 참조 requestId: ${requestId}` : null,
    referencedQuestion ? `- 참조 사용자 요청: ${normalizePromptContextText(referencedQuestion, 1200)}` : null,
    referencedAnswer
      ? `- 참조 답변 본문: ${normalizePromptContextText(referencedAnswer, 4000)}`
      : '- 참조 답변 본문: 아직 저장된 답변 본문이 없어 상위 요청만 참조합니다.',
    '- 사용자가 "방금 어떤 답변을 참조했냐"처럼 물으면 위 requestId와 참조 본문을 기준으로 답하고, 다른 최근 답변을 임의로 섞지 마세요.',
  ].filter((line): line is string => Boolean(line));
}

export function buildAgenticCodexPrompt(
  context: ChatContext | null,
  input: string,
  sessionId: string,
  promptContext?: {
    repoPath?: string;
    recentHistoryLines?: string[];
    omittedHistoryCount?: number;
    sessionReferenceResourcePath?: string;
    sessionReferenceContent?: string;
    referencedRequest?: ChatConversationRequestItem | null;
  },
) {
  const repoPath = promptContext?.repoPath?.trim() || resolveMainProjectRoot();
  const chatSessionResourceDir = `public/.codex_chat/${sessionId}/resource`;
  const chatSessionUploadDir = `${chatSessionResourceDir}/uploads`;
  const sessionReferenceResourcePath =
    promptContext?.sessionReferenceResourcePath || `${chatSessionResourceDir}/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`;
  const recentHistoryLines = promptContext?.recentHistoryLines ?? [];
  const omittedHistoryCount = Math.max(0, promptContext?.omittedHistoryCount ?? 0);

  return [
    '당신은 이 저장소에서 Codex Live 요청을 처리하는 실제 Codex 실행기입니다.',
    'Codex Live는 Plan 자동화와 별개입니다. 자동화 유형 context를 Codex Live 기본 문맥으로 섞지 마세요.',
    `저장소 루트(main_project): ${repoPath}`,
    '반드시 저장소 루트의 AGENTS.md를 먼저 읽고 그 규칙을 따르세요.',
    '가능한 작업 범위:',
    '- 로컬 소스 파일 읽기',
    '- 필요 시 DB 직접 조회',
    '- 필요 시 로컬 API 응답 확인',
    '- 사용자가 요청했거나 해결에 필요하면 소스 코드 수정',
    '- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md를 먼저 확인하고 그 규칙 안에서만 작업하세요.',
    `- 현재 채팅 세션 리소스 기본 경로: ${chatSessionResourceDir}/`,
    `- 현재 채팅 첨부 업로드 경로: ${chatSessionUploadDir}/`,
    `- 이 채팅방의 지속 참고 문서: ${sessionReferenceResourcePath}`,
    '- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.',
    '- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
    '- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
    '- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.',
    '- 참고 문서는 필요한 기준만 짧게 유지하고, 최근 대화 요약이나 과한 메모 누적은 만들지 마세요.',
    '- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.',
    ...buildChatSessionReferenceInstructionBlock(promptContext?.sessionReferenceContent),
    '',
    ...buildChatTypeInstructionBlock(context),
    ...(promptContext?.referencedRequest ? ['', ...buildReferencedRequestInstructionBlock(promptContext.referencedRequest)] : []),
    '',
    '응답 규칙:',
    '- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
    '- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
    '- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.',
    '- 참고 화면 정보의 `pageTitle`과 `pageUrl`은 현재 열려 있는 Codex Live 채팅 컨테이너 정보일 수 있습니다. 리소스 관리 등록 경로의 `수정한 화면명`, 작업 뱃지, 결과 문구에 이 값을 그대로 복사하지 말고 실제로 수정하거나 확인한 화면/메뉴 기준으로 판단하세요.',
    '- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.',
    '- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.',
    '- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 문법은 외부 공개 링크에만 사용하고, `/api/chat/resources/...` 같은 내부 리소스에는 사용하지 마세요. URL은 `https://...` 원문 그대로 쓰고 구분자 `|`를 인코딩하지 마세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.',
    '- 단계형 선택지를 채팅방 컴포넌트로 보여주려면 `[[prompt:{"title":"질문","description":"설명","submitLabel":"선택 전달","mode":"queue","options":[{"label":"옵션 A","value":"option-a","description":"설명","preview":{"type":"image","url":"https://..."}}],"responseTemplate":"{{selection_label}} 기준으로 다음 단계를 이어서 진행해 주세요."}]]` 한 줄 JSON 문법을 사용하세요. stepper가 필요하면 `steps` 배열을 추가해 `{"key":"scope","title":"범위 선택","options":[...]}` 형태로 단계별 옵션을 나누고, 단계별 `optional`, `multiple`, `freeTextLabel`, `freeTextPlaceholder`, `responseTemplate`도 사용할 수 있습니다. 옵션별 `preview`는 `image`, `markdown`, `html`, `resource`를 지원하고, 아이콘 버튼으로 확대 preview 할 수 있습니다. HTML 문서를 iframe으로 바로 보여줘야 할 때는 현재 앱 URL이나 `/chat/...` 경로를 넣지 말고, 먼저 실제 `.html` 리소스를 준비한 뒤 `preview":{"type":"resource","url":"/api/chat/resources/.../sample.html"}` 또는 `preview":{"type":"resource","url":"resource/<수정한 화면명>/<기능>/<YYYYMMDD>/sample.html"}` 형태를 사용하세요. 리소스 관리에 등록된 `resource/...` 경로는 자동으로 `/api/resource-manager/preview/...`로 해석됩니다. `type:"html"`은 HTML 본문 문자열을 `content`에 직접 넣을 때만 사용합니다. 이미 결정된 결과를 읽기 전용으로 보여줄 때는 `readOnly`, `selectedValues`, `resolvedBy`, `resultText`를 함께 넣을 수 있습니다. 이 줄은 본문에서 숨겨지고 prompt 컴포넌트로 렌더됩니다.',
    '- 변경된 소스 파일이 있는 경우에만 최종 답변에 변경 이력을 ```diff 코드블록으로 포함하세요.',
    '- 변경된 소스 파일이 있으면 마지막에 해당 파일 경로를 짧게 적으세요.',
    '- 한국어로 간결하게 답하세요.',
    '',
    '현재 채팅 컨테이너 참고 정보:',
    `- pageTitle: ${context?.pageTitle ?? '없음'}`,
    `- topMenu: ${context?.topMenu ?? '없음'}`,
    `- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`,
    `- pageUrl: ${context?.pageUrl ?? '없음'}`,
    '',
    '최근 대화 문맥(보조 참조):',
    ...(recentHistoryLines.length > 0
      ? [
          ...recentHistoryLines.map((line) => `- ${line}`),
          ...(omittedHistoryCount > 0
            ? [`- 최근 문맥 일부만 포함했습니다. 이전 ${omittedHistoryCount}개 메시지는 제외되었습니다.`]
            : []),
        ]
      : ['- 참조할 최근 대화가 없습니다.']),
    '',
    '사용자 요청:',
    input,
    '',
    '최종 답변만 출력하세요.',
  ].join('\n');
}

export async function validateAgenticCodexRuntime(repoPath: string, codexBin: string) {
  const issues: string[] = [];

  try {
    const repoStat = await stat(repoPath);

    if (!repoStat.isDirectory()) {
      issues.push(`PLAN_MAIN_PROJECT_REPO_PATH 경로가 디렉터리가 아닙니다: ${repoPath}`);
    }
  } catch {
    issues.push(`PLAN_MAIN_PROJECT_REPO_PATH 경로를 찾지 못했습니다: ${repoPath}`);
  }

  const runnerCandidates = buildCommandRunnerApiCandidates('/health');
  const headers = new Headers();

  if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim()) {
    headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim());
  }

  let runnerReachable = false;

  for (const candidate of runnerCandidates) {
    try {
      const response = await fetch(candidate, {
        method: 'GET',
        headers,
        signal: AbortSignal.timeout(5000),
      });

      if (response.ok) {
        runnerReachable = true;
        break;
      }
    } catch {
      // try next candidate
    }
  }

  if (!runnerReachable) {
    issues.push(`SERVER_COMMAND_RUNNER_URL 경로로 Codex runner에 연결하지 못했습니다: ${env.SERVER_COMMAND_RUNNER_URL}`);
  }

  if (issues.length > 0) {
    throw new Error(`채팅 실행 환경이 준비되지 않았습니다. ${issues.join(' / ')}`);
  }
}

function normalizeRunnerUrl(value: string) {
  return value.trim().replace(/\/+$/, '');
}

function buildCommandRunnerApiCandidates(requestPath: string) {
  const configuredHealthUrl = env.SERVER_COMMAND_RUNNER_URL?.trim() || 'http://host.docker.internal:3211/health';

  let parsedUrl: URL;

  try {
    parsedUrl = new URL(configuredHealthUrl);
  } catch {
    return [];
  }

  const hostVariants =
    parsedUrl.hostname === 'host.docker.internal'
      ? ['host.docker.internal', '127.0.0.1', 'localhost']
      : parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost'
        ? [parsedUrl.hostname, parsedUrl.hostname === '127.0.0.1' ? 'localhost' : '127.0.0.1', 'host.docker.internal']
        : [parsedUrl.hostname];

  const deduped: string[] = [];

  for (const hostname of hostVariants) {
    const candidate = new URL(parsedUrl.toString());
    candidate.hostname = hostname;
    candidate.pathname = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
    candidate.search = '';
    candidate.hash = '';
    const serialized = normalizeRunnerUrl(candidate.toString());

    if (!deduped.includes(serialized)) {
      deduped.push(serialized);
    }
  }

  return deduped;
}

async function requestCommandRunner(requestPath: string, init?: RequestInit) {
  const headers = new Headers(init?.headers);

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

  if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() && !headers.has('X-Access-Token')) {
    headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim());
  }

  let lastError: Error | null = null;

  for (const url of buildCommandRunnerApiCandidates(requestPath)) {
    try {
      return await fetch(url, {
        ...init,
        headers,
      });
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));
    }
  }

  throw lastError ?? new Error('command-runner에 연결하지 못했습니다.');
}

async function cancelRunnerCodexExecution(requestId: string) {
  try {
    const response = await requestCommandRunner(`/api/codex-live/jobs/${encodeURIComponent(requestId)}/cancel`, {
      method: 'POST',
    });

    if (!response.ok) {
      return false;
    }

    const payload = (await response.json().catch(() => null)) as { cancelled?: boolean } | null;
    return payload?.cancelled === true;
  } catch {
    return false;
  }
}

function isRunnerTerminatedMessage(message: string) {
  const normalizedMessage = message.trim().toLowerCase();
  return normalizedMessage === 'terminated' || normalizedMessage.endsWith(': terminated');
}

function normalizeRunnerExecutionErrorMessage(message: string) {
  if (isRunnerTerminatedMessage(message)) {
    return 'Codex 실행이 중간에 종료되었습니다. 잠시 후 다시 시도해 주세요.';
  }

  return message;
}

async function runAgenticCodexReply(
  context: ChatContext | null,
  input: string,
  sessionId: string,
  requestId: string,
  options?: {
    omitPromptHistory?: boolean;
    requestedAt?: Date | null;
    parentRequestId?: string | null;
  },
  onProgress?: (text: string) => void,
  onActivity?: (line: string) => void,
  isCancellationRequested?: () => boolean,
): Promise<ChatCodexReplyResult> {
  const repoPath = resolveMainProjectRoot();
  await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
  const resolvedContext = await resolveCodexLiveChatContext(context, sessionId);
  const referencedRequest = options?.parentRequestId?.trim()
    ? await getChatConversationRequest(sessionId, options.parentRequestId.trim())
    : null;
  const appConfig = await getAppConfigSnapshot();
  const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, {
    maxMessages: appConfig.chat?.maxContextMessages,
    maxChars: appConfig.chat?.maxContextChars,
    disabled: options?.omitPromptHistory === true,
  });
  const codexLiveMaxExecutionSeconds =
    typeof appConfig.chat?.codexLiveMaxExecutionSeconds === 'number' &&
    Number.isFinite(appConfig.chat.codexLiveMaxExecutionSeconds)
      ? Math.min(7200, Math.max(60, Math.round(appConfig.chat.codexLiveMaxExecutionSeconds)))
      : null;
  const codexLiveIdleTimeoutSeconds =
    typeof appConfig.chat?.codexLiveIdleTimeoutSeconds === 'number' &&
    Number.isFinite(appConfig.chat.codexLiveIdleTimeoutSeconds)
      ? Math.min(3600, Math.max(30, Math.round(appConfig.chat.codexLiveIdleTimeoutSeconds)))
      : null;
  const requestedAt = options?.requestedAt ?? new Date();
  const syncSessionReference = async (
    requestStatus: 'started' | 'completed' | 'failed' | 'cancelled',
    completedAt?: Date | null,
  ) =>
    ensureChatSessionReferenceResource({
      repoPath,
      sessionId,
      requestId,
      context: resolvedContext,
      requestStatus,
      requestedAt,
      completedAt: completedAt ?? null,
      input,
      recentHistoryLines: recentHistory.items,
      omittedHistoryCount: recentHistory.omittedCount,
    });
  const sessionReferenceResourcePath = await ensureChatSessionReferenceResource({
    repoPath,
    sessionId,
    requestId,
    context: resolvedContext,
    requestStatus: 'started',
    requestedAt,
    input,
    recentHistoryLines: recentHistory.items,
    omittedHistoryCount: recentHistory.omittedCount,
  });
  const sessionReferenceAbsolutePath = path.join(repoPath, sessionReferenceResourcePath);
  let sessionReferenceContent = '';

  try {
    sessionReferenceContent = await readFile(sessionReferenceAbsolutePath, 'utf8');
  } catch {
    sessionReferenceContent = '';
  }
  const prompt = buildAgenticCodexPrompt(resolvedContext, input, sessionId, {
    repoPath,
    recentHistoryLines: recentHistory.items,
    omittedHistoryCount: recentHistory.omittedCount,
    sessionReferenceResourcePath,
    sessionReferenceContent,
    referencedRequest,
  });
  let streamedOutput = '';
  let stdoutTail = '';
  let stderr = '';
  let jsonLineBuffer = '';
  let lastProgressText = '';
  let completedAgentMessage = '';
  let streamedUsageSnapshot: ChatConversationRequestUsageSnapshot | null = null;
  let hasIncrementalDelta = false;
  const finalizeReplyOutput = async () => {
    const normalizedOutput = normalizeCodexReplyOutput(completedAgentMessage || streamedOutput || stdoutTail);
    const rewrittenOutput = await rewriteCodexOutputWithChatResources(normalizedOutput, repoPath, sessionId);
    const usageSnapshot =
      streamedUsageSnapshot ??
      extractChatRequestUsageSnapshot([completedAgentMessage, stdoutTail, stderr].filter(Boolean).join('\n'));

    if (!rewrittenOutput) {
      return {
        text: '',
        usageSnapshot,
        totalTokens: usageSnapshot?.totalTokens ?? null,
      };
    }

    // If the CLI only produced a final completed event, avoid sending it as one big batch.
    if (!hasIncrementalDelta && rewrittenOutput) {
      await streamReplyChunks(rewrittenOutput, onProgress);
    } else if (rewrittenOutput !== lastProgressText) {
      onProgress?.(rewrittenOutput);
    }

    return {
      text: rewrittenOutput,
      usageSnapshot,
      totalTokens: usageSnapshot?.totalTokens ?? null,
    };
  };
  const throwIfCancelled = async () => {
    if (!isCancellationRequested?.()) {
      return;
    }

    await cancelRunnerCodexExecution(requestId).catch(() => false);
    throw new Error('CHAT_RUNTIME_CANCELLED');
  };

  await throwIfCancelled();
  activeChatProcessRegistry.set(requestId, {
    cancel: async () => {
      const cancelled = await cancelRunnerCodexExecution(requestId);
      return cancelled || isCancellationRequested?.() === true;
    },
  });

  chatRuntimeService.appendLog(
    requestId,
    `실행 제한 설정을 적용했습니다. 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`,
  );
  onActivity?.(
    `# 설정: 최대 실행 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`,
  );

  try {
    await new Promise<void>(async (resolve, reject) => {
      const emitProgress = (nextText: string) => {
        const normalizedProgress = nextText.trim();

        if (!normalizedProgress || normalizedProgress === lastProgressText) {
          return;
        }

        lastProgressText = normalizedProgress;
        streamedOutput = normalizedProgress;
        onProgress?.(normalizedProgress);
      };

      try {
        await throwIfCancelled();
        const response = await requestCommandRunner('/api/codex-live/execute', {
          method: 'POST',
          body: JSON.stringify({
            requestId,
            sessionId,
            repoPath,
            prompt,
            model: context?.codexModel?.trim() || null,
            resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'),
            uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'),
            maxExecutionSeconds: codexLiveMaxExecutionSeconds,
            idleTimeoutSeconds: codexLiveIdleTimeoutSeconds,
          }),
        });

        if (!response.ok) {
          reject(new Error((await response.text()) || 'command-runner Codex 실행 요청에 실패했습니다.'));
          return;
        }

        await throwIfCancelled();

        if (!response.body) {
          reject(new Error('command-runner Codex 스트림이 비어 있습니다.'));
          return;
        }

        chatRuntimeService.appendLog(requestId, 'Codex 실행을 command-runner API로 요청했습니다.');
        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        let remoteErrorMessage = '';

        const handleRunnerLine = (line: string) => {
          let parsed: Record<string, unknown>;

          try {
            parsed = JSON.parse(line) as Record<string, unknown>;
          } catch {
            return;
          }

          const eventType = typeof parsed.type === 'string' ? parsed.type : '';

          if (eventType === 'started') {
            const pid = typeof parsed.pid === 'number' && Number.isFinite(parsed.pid) ? parsed.pid : null;
            const model = typeof parsed.model === 'string' && parsed.model.trim() ? parsed.model.trim() : null;
            const appliedIdleTimeoutSeconds =
              typeof parsed.configuredIdleTimeoutSeconds === 'number' &&
              Number.isFinite(parsed.configuredIdleTimeoutSeconds)
                ? Math.round(parsed.configuredIdleTimeoutSeconds)
                : null;
            const appliedMaxExecutionSeconds =
              typeof parsed.configuredMaxExecutionSeconds === 'number' &&
              Number.isFinite(parsed.configuredMaxExecutionSeconds)
                ? Math.round(parsed.configuredMaxExecutionSeconds)
                : null;
            chatRuntimeService.attachProcess(requestId, pid);
            chatRuntimeService.appendLog(
              requestId,
              pid
                ? `호스트 command-runner에서 Codex 프로세스를 시작했습니다. pid=${pid}`
                : '호스트 command-runner에서 Codex 프로세스를 시작했습니다.',
            );
            if (model) {
              chatRuntimeService.appendLog(requestId, `선택 모델: ${model}`);
            }
            if (appliedMaxExecutionSeconds != null || appliedIdleTimeoutSeconds != null) {
              const appliedSummary =
                `command-runner 적용값: 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` +
                `무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}초`;
              chatRuntimeService.appendLog(requestId, appliedSummary);
              onActivity?.(`# ${appliedSummary}`);

              if (
                (appliedMaxExecutionSeconds != null &&
                  codexLiveMaxExecutionSeconds != null &&
                  appliedMaxExecutionSeconds !== codexLiveMaxExecutionSeconds) ||
                (appliedIdleTimeoutSeconds != null &&
                  codexLiveIdleTimeoutSeconds != null &&
                  appliedIdleTimeoutSeconds !== codexLiveIdleTimeoutSeconds)
              ) {
                const mismatchSummary =
                  `설정 불일치 감지: 요청값 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / ` +
                  `무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초, ` +
                  `실제 적용값 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` +
                  `무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}초`;
                chatRuntimeService.appendLog(requestId, mismatchSummary);
                onActivity?.(`# 경고: ${mismatchSummary}`);
              }
            }
            return;
          }

          if (eventType === 'attached') {
            const attachedRequestId = String(parsed.requestId ?? '').trim();
            const completed = parsed.completed === true;
            const attachSummary =
              attachedRequestId && attachedRequestId === requestId
                ? completed
                  ? '기존 command-runner 실행 이력을 다시 연결했습니다.'
                  : '기존 command-runner 실행에 재부착했습니다.'
                : completed
                  ? '기존 command-runner 응답 이력을 다시 연결했습니다.'
                  : '기존 command-runner 실행 스트림에 다시 연결했습니다.';
            chatRuntimeService.appendLog(requestId, attachSummary);
            onActivity?.(`# ${attachSummary}`);
            return;
          }

          if (eventType === 'activity') {
            const activityLog = String(parsed.line ?? '').trim();

            if (activityLog) {
              chatRuntimeService.appendLog(requestId, activityLog);
              onActivity?.(activityLog);
            }
            return;
          }

          if (eventType === 'delta') {
            const deltaText = String(parsed.text ?? '');

            if (deltaText) {
              hasIncrementalDelta = true;
              emitProgress(`${streamedOutput}${deltaText}`);
            }
            return;
          }

          if (eventType === 'completed') {
            completedAgentMessage = String(parsed.text ?? '').trim();
            const usageSnapshot =
              parsed.usageSnapshot && typeof parsed.usageSnapshot === 'object'
                ? (parsed.usageSnapshot as ChatConversationRequestUsageSnapshot)
                : null;

            if (usageSnapshot) {
              streamedUsageSnapshot = usageSnapshot;
            }

            if (completedAgentMessage && hasIncrementalDelta) {
              emitProgress(completedAgentMessage);
            }
            return;
          }

          if (eventType === 'usage') {
            const usageSnapshot =
              parsed.usageSnapshot && typeof parsed.usageSnapshot === 'object'
                ? (parsed.usageSnapshot as ChatConversationRequestUsageSnapshot)
                : null;

            if (usageSnapshot) {
              streamedUsageSnapshot = usageSnapshot;
            }
            return;
          }

          if (eventType === 'stdout') {
            const stdoutLine = String(parsed.line ?? '').trim();

            if (stdoutLine) {
              const structuredStdout = parseStructuredCodexStdoutLine(stdoutLine);

              if (!structuredStdout.shouldKeepRaw) {
                if (structuredStdout.activityLog) {
                  chatRuntimeService.appendLog(requestId, structuredStdout.activityLog);
                  onActivity?.(structuredStdout.activityLog);
                }

                if (structuredStdout.deltaText) {
                  hasIncrementalDelta = true;
                  emitProgress(`${streamedOutput}${structuredStdout.deltaText}`);
                }

                if (structuredStdout.completedText) {
                  completedAgentMessage = structuredStdout.completedText.trim();

                  if (completedAgentMessage && hasIncrementalDelta) {
                    emitProgress(completedAgentMessage);
                  }
                }

                if (structuredStdout.usageSnapshot) {
                  streamedUsageSnapshot = structuredStdout.usageSnapshot;
                }

                return;
              }

              stdoutTail = `${stdoutTail}\n${stdoutLine}`.slice(-STREAM_CAPTURE_LIMIT);
              chatRuntimeService.appendLog(requestId, `[stdout] ${stdoutLine}`);
              onActivity?.(`[stdout] ${stdoutLine}`);
            }
            return;
          }

          if (eventType === 'stderr') {
            const stderrLine = String(parsed.line ?? '').trim();

            if (stderrLine) {
              stderr = `${stderr}\n${stderrLine}`.slice(-STREAM_CAPTURE_LIMIT);
              chatRuntimeService.appendLog(requestId, `[stderr] ${stderrLine}`);
              onActivity?.(`[stderr] ${stderrLine}`);
            }
            return;
          }

          if (eventType === 'error') {
            remoteErrorMessage = String(parsed.message ?? '').trim();
          }
        };

        while (true) {
          await throwIfCancelled();
          const { value, done } = await reader.read();

          if (done) {
            break;
          }

          jsonLineBuffer += decoder.decode(value, { stream: true });
          const lines = jsonLineBuffer.split('\n');
          jsonLineBuffer = lines.pop() ?? '';

          for (const rawLine of lines) {
            const line = rawLine.trim();

            if (!line) {
              continue;
            }

            handleRunnerLine(line);
          }
        }

        const trailingLine = jsonLineBuffer.trim();
        if (trailingLine) {
          handleRunnerLine(trailingLine);
        }

        if (remoteErrorMessage) {
          const hasMeaningfulOutput = Boolean(
            completedAgentMessage.trim() || streamedOutput.trim() || stdoutTail.trim(),
          );

          if (isRunnerTerminatedMessage(remoteErrorMessage) && hasMeaningfulOutput) {
            resolve();
            return;
          }

          reject(new Error(normalizeRunnerExecutionErrorMessage(remoteErrorMessage)));
          return;
        }

        resolve();
      } catch (error) {
        reject(error);
      }
    });
  } catch (error) {
    const completedAt = new Date();
    await syncSessionReference(error instanceof Error && error.message === 'CHAT_RUNTIME_CANCELLED' ? 'cancelled' : 'failed', completedAt);
    const failureResponseText = await finalizeReplyOutput();

    if (failureResponseText.text) {
      throw new ChatRuntimeExecutionError(
        error instanceof Error ? error.message : 'Codex 실행에 실패했습니다.',
        failureResponseText.text,
        failureResponseText.usageSnapshot,
        failureResponseText.totalTokens,
      );
    }

    throw error;
  }

  await syncSessionReference('completed', new Date());
  return await finalizeReplyOutput();
}

async function getTodayAutomationRegistrationCounts() {
  const [planCountResult, boardReceivedCountResult] = await Promise.all([
    db(PLAN_TABLE)
      .whereNot('automation_type', 'none')
      .whereRaw("(created_at at time zone ?)::date = (current_timestamp at time zone ?)::date", [KST_TIME_ZONE, KST_TIME_ZONE])
      .count<{ count: string }>('* as count')
      .first(),
    db(BOARD_POSTS_TABLE)
      .whereNotNull('automation_received_at')
      .whereRaw(
        "(automation_received_at at time zone ?)::date = (current_timestamp at time zone ?)::date",
        [KST_TIME_ZONE, KST_TIME_ZONE],
      )
      .count<{ count: string }>('* as count')
      .first(),
  ]);

  return {
    today: formatKstDate(),
    planCount: Number(planCountResult?.count ?? 0),
    boardReceivedCount: Number(boardReceivedCountResult?.count ?? 0),
  };
}

async function buildAutomationRegistrationCountReply() {
  const counts = await getTodayAutomationRegistrationCounts();
  const lines = [
    '결과',
    `- ${counts.today} KST 기준 오늘 자동화 등록 총 건수는 ${counts.planCount}건입니다.`,
    '- 기본 기준: `plan_items`에서 `automation_type <> \'none\'` 이고 오늘 생성된 건수',
  ];

  if (counts.planCount === counts.boardReceivedCount) {
    lines.push(`- 교차 확인: 오늘 ` + '`board_posts.automation_received_at`' + ` 기준 접수 건수도 ${counts.boardReceivedCount}건으로 같습니다.`);
  } else {
    lines.push(`- 참고: 오늘 ` + '`board_posts.automation_received_at`' + ` 기준 접수 건수는 ${counts.boardReceivedCount}건입니다.`);
    lines.push('- 이 프로젝트는 게시판 등록, 자동화 접수, Plan 생성이 분리될 수 있어 기준에 따라 숫자가 달라질 수 있습니다.');
  }

  return lines.join('\n');
}

function buildAutomationRegistrationDefinitionReply() {
  return [
    '결과',
    '- 이 프로젝트에서 `자동화 등록`은 하나로 고정된 용어가 아닙니다.',
    '- `board_posts.created_at`: 게시판 등록',
    '- `board_posts.automation_received_at`: 자동화 접수',
    '- `plan_items.created_at` + `automation_type <> \'none\'`: 실제 자동화 대상 Plan 생성',
    '- 건수 질문이면 어떤 기준인지 먼저 구분하고, 모호하면 기본적으로 Plan 생성 기준을 우선 안내합니다.',
  ].join('\n');
}

function buildProgressMessages(input: string) {
  const messages = ['요청을 분석하고 있습니다.'];

  if (isAutomationRegistrationCountRequest(input)) {
    messages.push('오늘 기준 집계라 DB 기준과 시간대 기준을 확인하고 있습니다.');
    return messages;
  }

  if (/db|데이터베이스|sql|쿼리|집계|건수/i.test(input)) {
    messages.push('DB와 집계 기준을 확인하고 있습니다.');
  }

  if (/api|응답|endpoint|엔드포인트|fetch|호출/i.test(input)) {
    messages.push('API 경로와 실제 응답을 확인하고 있습니다.');
  }

  if (/파일|소스|코드|tsx|ts|js|css|수정|변경|구현|fix|edit|implement/i.test(input)) {
    messages.push('관련 소스와 연결 흐름을 확인하고 있습니다.');
  }

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

function normalizeProgressSummary(text: string) {
  return text.replace(/\s+/g, ' ').trim();
}

function shouldSkipActivityProgressSummary(text: string) {
  return (
    !text ||
    /^요청을 처리합니다\./.test(text) ||
    /^응답 생성이 완료되었습니다\.$/.test(text) ||
    /^완료(?:\(\d+\))?$/i.test(text) ||
    /^종료\(\d+\)$/i.test(text)
  );
}

export function summarizeActivityProgressLine(activityLine: string) {
  const lines = String(activityLine ?? '')
    .split('\n')
    .map((line) => normalizeProgressSummary(line))
    .filter(Boolean);

  for (const line of lines) {
    if (line.startsWith('# 이유:')) {
      const summary = normalizeProgressSummary(line.slice('# 이유:'.length));
      if (!shouldSkipActivityProgressSummary(summary)) {
        return summary;
      }
    }
  }

  for (const line of lines) {
    for (const prefix of ['# 진행:', '# 상태:', '# 경고:']) {
      if (!line.startsWith(prefix)) {
        continue;
      }

      const summary = normalizeProgressSummary(line.slice(prefix.length));
      if (!shouldSkipActivityProgressSummary(summary)) {
        return summary;
      }
    }
  }

  return '';
}

function stripActivityLinePrefix(line: string) {
  return line.replace(/^#\s*(상태|진행|이유|경고|오류):\s*/u, '').trim();
}

function resolveCompactActivityStageLineNo(summary: string, normalizedLine: string) {
  if (!summary) {
    return null;
  }

  if (/^#\s*오류:/u.test(normalizedLine) || /오류|실패|중단/u.test(summary)) {
    return 5;
  }

  if (/요청을 처리합니다|대기열|즉시 요청 실행/u.test(summary)) {
    return 1;
  }

  if (/분석|의도|문맥|생각 중/u.test(summary)) {
    return 2;
  }

  if (/\bdb\b|데이터베이스|\bapi\b|엔드포인트|응답|소스|코드|파일|흐름|쿼리|집계|resource|리소스|화면/u.test(summary)) {
    return 3;
  }

  if (/구현|수정|변경|작성|빌드|patch|diff|전송 중/u.test(summary)) {
    return 4;
  }

  if (/검증|테스트|캡처|preview|완료|결과|정리/u.test(summary)) {
    return 5;
  }

  return null;
}

function createCompactActivityLogEntry(activityLine: string) {
  const normalizedLine = normalizeProgressSummary(activityLine);

  if (!normalizedLine) {
    return null;
  }

  const strippedSummary = stripActivityLinePrefix(normalizedLine);
  const summarizedProgress = summarizeActivityProgressLine(normalizedLine);
  const summary = summarizedProgress || strippedSummary;
  const lineNo = resolveCompactActivityStageLineNo(summary, normalizedLine);

  if (!lineNo || !summary) {
    return null;
  }

  if (/^#\s*오류:/u.test(normalizedLine)) {
    return {
      line: `# 오류: ${summary}`,
      lineNo,
    };
  }

  const prefix = lineNo === 1 || lineNo === 5 ? '# 상태:' : '# 진행:';

  return {
    line: `${prefix} ${summary}`,
    lineNo,
  };
}

function buildGenericReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) {
  const normalized = input.toLowerCase();
  const pageTitle = context?.pageTitle ?? '현재 화면';
  const previewUrl = context?.pageUrl || '없음';
  const detailRequested = isDetailRequest(input);

  if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) {
    const lines = [
      '결과',
      `- preview: ${snapshot?.latestSourceWork?.previewUrl ?? previewUrl}`,
    ];

    if (snapshot?.latestSourceWork?.summary) {
      lines.push(`- 요약: ${truncateText(snapshot.latestSourceWork.summary, 100)}`);
    }

    if (detailRequested && snapshot?.latestSourceWork) {
      lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`);
    }

    if (!detailRequested) {
      lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
    }

    return lines.join('\n');
  }

  if (input.includes('계획') || normalized.includes('plan')) {
    if (!snapshot) {
      return ['결과', '- 연결된 Plan을 찾지 못했습니다.', '- planId 또는 작업 ID를 함께 보내 주세요.'].join('\n');
    }

    const lines = [
      '작업 요약',
      `- #${snapshot.planId} ${snapshot.workId}`,
      `- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`,
      `- 메모: ${truncateText(snapshot.note, 110) || '기록 없음'}`,
    ];

    if (detailRequested) {
      if (snapshot.assignedBranch) {
        lines.push(`- 브랜치: ${snapshot.assignedBranch}`);
      }

      lines.push(...buildRecentPlanHistoryLines(snapshot));
    } else {
      lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
    }

    return lines.join('\n');
  }

  if ((input.includes('이슈') || normalized.includes('issue')) && snapshot) {
    const lines = ['이슈 요약'];

    if (snapshot.latestIssue) {
      lines.push(
        `- ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? '해결' : '미해결'}`,
        `- 내용: ${truncateText(snapshot.latestIssue.message, 110)}`,
      );
    } else if (snapshot.issueTags.length > 0) {
      lines.push(`- 태그: ${snapshot.issueTags.join(', ')}`);
    } else {
      lines.push('- 최근 이슈 이력이 없습니다.');
    }

    if (detailRequested) {
      lines.push(...buildRecentPlanHistoryLines(snapshot));
    }

    return lines.join('\n');
  }

  if ((input.includes('이력') || input.includes('최근') || normalized.includes('history')) && snapshot) {
    const lines = [
      '최근 이력',
      `- ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 120) || '최근 작업 이력이 없습니다.'}`,
    ];

    if (detailRequested) {
      lines.push(...buildRecentPlanHistoryLines(snapshot));
    } else {
      lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
    }

    return lines.join('\n');
  }

  if (input.includes('문서') || normalized.includes('docs')) {
    return ['결과', '- 문서 요청으로 인식했습니다.', '- 대상 문서명이나 링크를 함께 보내 주세요.'].join('\n');
  }

  if (isWorklogRequest(input)) {
    if (snapshot) {
      return buildWorklogReply(context, snapshot);
    }

    return ['결과', '- 작업로그 초안을 만들 Plan이 없습니다.', '- planId 또는 작업 화면에서 다시 요청해 주세요.'].join('\n');
  }

  if (
    input.includes('컴포넌트') ||
    normalized.includes('widget') ||
    normalized.includes('api')
  ) {
    return ['결과', '- 대상 리소스를 특정해 주세요.', '- 예: 버튼 컴포넌트, previewer, history api'].join('\n');
  }

  if (snapshot && isPlanDetailRequest(input)) {
    const lines = [
      '작업 요약',
      `- #${snapshot.planId} ${snapshot.workId}`,
      `- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`,
      `- 최근 작업: ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 110) || '기록 없음'}`,
    ];

    if (detailRequested) {
      lines.push(...buildRecentPlanHistoryLines(snapshot));
    } else {
      lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
    }

    return lines.join('\n');
  }

  return [
    '결과',
    `- 현재 화면: ${pageTitle}`,
    '- 요청을 더 구체적으로 적어 주시면 필요한 정보만 짧게 정리합니다.',
    '- 예: preview 링크, 최근 이슈, 작업 요약, 상세',
  ].join('\n');
}

function buildPlanReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) {
  const normalized = input.toLowerCase();
  const detailRequested = isDetailRequest(input);
  const pageTitle = context?.pageTitle ?? '현재 화면';

  if (!snapshot) {
    return `현재 화면: ${pageTitle}\n선택된 Plan을 찾지 못했습니다. Plan 목록에서 항목을 선택한 뒤 다시 요청해 주세요.`;
  }

  const lines = ['작업 요약', `- #${snapshot.planId} ${snapshot.workId}`, `- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`];

  if (isWorklogRequest(input)) {
    return buildWorklogReply(context, snapshot);
  }

  if (snapshot.note) {
    lines.push(`- 메모: ${truncateText(snapshot.note, 110)}`);
  }

  if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) {
    lines.push(`- preview: ${snapshot.latestSourceWork?.previewUrl ?? context?.pageUrl ?? '없음'}`);
    if (snapshot.latestSourceWork) {
      lines.push(`- 최근 작업: ${truncateText(snapshot.latestSourceWork.summary, 100)}`);
      if (detailRequested) {
        lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`);
      }
    }

    if (!detailRequested) {
      lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
    }

    return lines.join('\n');
  }

  if (snapshot.latestIssue) {
    lines.push(`- 최근 이슈: ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? '해결' : '미해결'}`);
    if (detailRequested) {
      lines.push(`- 이슈 내용: ${truncateText(snapshot.latestIssue.message, 100)}`);
    }
  } else if (snapshot.issueTags.length > 0) {
    lines.push(`- 이슈 태그: ${snapshot.issueTags.join(', ')}`);
  }

  if (snapshot.latestActionNote) {
    lines.push(`- 최근 조치: ${truncateText(snapshot.latestActionNote, 100)}`);
  }

  if (snapshot.latestSourceWork) {
    lines.push(`- 최근 작업: ${truncateText(snapshot.latestSourceWork.summary, 100)}`);
    if (detailRequested && snapshot.latestSourceWork.changedFiles.length > 0) {
      lines.push(`- 파일 ${snapshot.latestSourceWork.changedFiles.length}개 변경`);
    }
  }

  if (snapshot.lastError && snapshot.hasOpenIssues) {
    lines.push(`- 주의: ${truncateText(snapshot.lastError, 100)}`);
  }

  if (detailRequested) {
    if (snapshot.assignedBranch) {
      lines.push(`- 브랜치: ${snapshot.assignedBranch}`);
    }
    lines.push(...buildRecentPlanHistoryLines(snapshot));
  } else {
    lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.');
  }

  return lines.join('\n');
}

async function buildCodexReply(
  context: ChatContext | null,
  input: string,
  sessionId: string,
  requestId: string,
  options?: {
    omitPromptHistory?: boolean;
    requestedAt?: Date | null;
    parentRequestId?: string | null;
  },
  onProgress?: (text: string) => void,
  onActivity?: (line: string) => void,
  isCancellationRequested?: () => boolean,
): Promise<ChatCodexReplyResult> {
  return runAgenticCodexReply(
    context,
    input,
    sessionId,
    requestId,
    options,
    onProgress,
    onActivity,
    isCancellationRequested,
  );
}

function formatParticipantTurnLabel(participant: {
  name: string;
  model: string;
  turn: CodexParticipantTurn;
}) {
  const suffix =
    participant.turn === 'opening'
      ? '중재 시작'
      : participant.turn === 'discussion'
        ? '프리토킹'
        : participant.turn === 'review'
          ? '최종 검토'
        : participant.turn === 'closing'
          ? '최종 정리'
          : null;

  return suffix ? `${participant.name}(${participant.model}, ${suffix})` : `${participant.name}(${participant.model})`;
}

type ResolvedCodexParticipant = ReturnType<typeof resolveCodexParticipantsForExecution>[number];

type CodexExecutionStage = {
  parallel: boolean;
  participants: ResolvedCodexParticipant[];
};

type CodexExecutionPlanStep = {
  key: string;
  label: string;
  lineNo: number;
  kind: 'analysis' | 'stage' | 'finalize';
  participantKeys?: string[];
};

type CodexExecutorActivitySlot = {
  participantId: string;
  label: string;
  lineNo: number;
};

function buildCodexPlanParticipantKey(participant: ResolvedCodexParticipant) {
  return `${participant.id}:${participant.turn}`;
}

export function resolveCodexParticipantsForExecution(context: ChatContext | null) {
  const configuredParticipants = normalizeCodexParticipants(context?.codexParticipants);
  const normalizedParticipants =
    configuredParticipants.length > 0
      ? configuredParticipants
      : [
          {
            id: 'codex-default',
            name: 'Codex',
            model: context?.codexModel?.trim() || 'gpt-5.4',
            prompt: '',
            chatTypeId: null,
            defaultContextIds: [],
            role: 'default' as const,
          },
        ];
  const executionPolicy = resolveChatTypeExecutionPolicy(context);
  const shouldAutoBind =
    executionPolicy.participantBinding === 'first-moderator-rest-conversation' ||
    executionPolicy.participantBinding === 'first-moderator-rest-conversation-last-reviewer';
  const policyBoundParticipants = shouldAutoBind
    ? normalizedParticipants.map((participant, index, source) => ({
        ...participant,
        role:
          index === 0
            ? ('moderator' as const)
            : executionPolicy.participantBinding === 'first-moderator-rest-conversation-last-reviewer' &&
                executionPolicy.reviewPolicy === 'reviewer' &&
                index === source.length - 1
              ? ('reviewer' as const)
              : ('conversation' as const),
      }))
    : normalizedParticipants;
  const moderator = policyBoundParticipants.find((participant) => participant.role === 'moderator') ?? null;

  if (!moderator) {
    return policyBoundParticipants.map((participant) => ({
      ...participant,
      turn:
        participant.role === 'conversation'
          ? ('discussion' as const)
          : participant.role === 'reviewer'
            ? ('review' as const)
            : ('standard' as const),
    }));
  }

  const reviewer =
    executionPolicy.reviewPolicy === 'reviewer'
      ? policyBoundParticipants.find((participant) => participant.role === 'reviewer') ?? null
      : null;
  const discussionParticipants = policyBoundParticipants.filter(
    (participant) => participant.id !== moderator.id && participant.id !== reviewer?.id,
  );

  if (discussionParticipants.length === 0 && !reviewer) {
    return [
      {
        ...moderator,
        turn: 'standard' as const,
      },
    ];
  }

  return [
    {
      ...moderator,
      turn: 'opening' as const,
    },
    ...discussionParticipants.map((participant) => ({
      ...participant,
      turn: participant.role === 'conversation' ? ('discussion' as const) : ('standard' as const),
    })),
    ...(reviewer
      ? [
          {
            ...reviewer,
            turn: 'review' as const,
          },
        ]
      : []),
    {
      ...moderator,
      turn: 'closing' as const,
    },
  ];
}

export function resolveCodexExecutionStages(
  context: ChatContext | null,
  requestMode: ChatRequestMode = 'queue',
): CodexExecutionStage[] {
  const participants = resolveCodexParticipantsForExecution(context);

  if (participants.length <= 1 || requestMode !== 'direct') {
    return participants.map((participant) => ({
      parallel: false,
      participants: [participant],
    }));
  }

  const executionPolicy = resolveChatTypeExecutionPolicy(context);

  if (!executionPolicy.finalSummaryRequired) {
    const parallelParticipants = participants
      .filter((participant) => participant.turn !== 'closing')
      .map((participant) =>
        participant.turn === 'opening'
          ? {
              ...participant,
              turn: 'standard' as const,
            }
          : participant,
      );

    return [
      {
        parallel: parallelParticipants.length > 1,
        participants: parallelParticipants,
      },
    ];
  }

  const stages: CodexExecutionStage[] = [];
  const openingParticipants = participants.filter((participant) => participant.turn === 'opening');
  const discussionParticipants = participants.filter(
    (participant) => participant.turn === 'discussion' || participant.turn === 'standard',
  );
  const reviewParticipants = participants.filter((participant) => participant.turn === 'review');
  const closingParticipants = participants.filter((participant) => participant.turn === 'closing');

  openingParticipants.forEach((participant) => {
    stages.push({
      parallel: false,
      participants: [participant],
    });
  });

  if (discussionParticipants.length > 0) {
    stages.push({
      parallel: discussionParticipants.length > 1,
      participants: discussionParticipants,
    });
  }

  reviewParticipants.forEach((participant) => {
    stages.push({
      parallel: false,
      participants: [participant],
    });
  });

  closingParticipants.forEach((participant) => {
    stages.push({
      parallel: false,
      participants: [participant],
    });
  });

  return stages;
}

function buildCodexExecutionPlanSteps(stages: CodexExecutionStage[]) {
  const steps: CodexExecutionPlanStep[] = [
    {
      key: 'analysis',
      label: '요청 분석',
      lineNo: 11,
      kind: 'analysis',
    },
  ];
  stages.forEach((stage, index) => {
    const turnSet = new Set(stage.participants.map((participant) => participant.turn));
    let label = stage.parallel ? `병렬 실행 (${stage.participants.length}명)` : '실행';

    if (turnSet.size === 1) {
      const [turn] = [...turnSet];

      if (turn === 'opening') {
        label = '중재 시작';
      } else if (turn === 'discussion') {
        label = stage.parallel ? `병렬 토론 (${stage.participants.length}명)` : '토론 실행';
      } else if (turn === 'review') {
        label = '최종 검토';
      } else if (turn === 'closing') {
        label = '최종 정리';
      } else if (turn === 'standard') {
        label = stage.parallel ? `병렬 실행 (${stage.participants.length}명)` : '단일 실행';
      }
    }

    steps.push({
      key: `stage-${index + 1}`,
      label,
      lineNo: 11 + steps.length,
      kind: 'stage',
      participantKeys: stage.participants.map((participant) => buildCodexPlanParticipantKey(participant)),
    });
  });

  steps.push({
    key: 'finalize',
    label: '응답 정리',
    lineNo: 11 + steps.length,
    kind: 'finalize',
  });

  return steps;
}

function buildCodexExecutorActivitySlots(participants: ResolvedCodexParticipant[], startLineNo: number) {
  const slots: CodexExecutorActivitySlot[] = [];
  const seenParticipantIds = new Set<string>();
  let lineOffset = startLineNo;

  participants.forEach((participant) => {
    if (seenParticipantIds.has(participant.id)) {
      return;
    }

    seenParticipantIds.add(participant.id);
    slots.push({
      participantId: participant.id,
      label: `${participant.name}(${participant.model})`,
      lineNo: lineOffset,
    });
    lineOffset += 1;
  });

  return slots;
}

async function resolveCodexParticipantTypeOverrides(
  participants: Array<{
    id: string;
    name: string;
    model: string;
    prompt: string;
    chatTypeId: string | null;
    role: CodexParticipantRole;
    turn: CodexParticipantTurn;
  }>,
  roomContext: ChatContext | null,
) {
  const participantTypeIds = Array.from(
    new Set(
      participants
        .map((participant) => participant.chatTypeId?.trim() || '')
        .filter((chatTypeId) => chatTypeId && chatTypeId !== (roomContext?.chatTypeId?.trim() || '')),
    ),
  );

  if (participantTypeIds.length === 0) {
    return new Map<
      string,
      {
        label: string;
        description: string;
      }
    >();
  }

  const [chatTypesConfig, chatContextSettings] = await Promise.all([
    getChatTypesConfig(),
    getChatContextSettingsConfig(),
  ]);

  return new Map(
    participantTypeIds.map((chatTypeId) => {
      const resolvedChatType =
        chatTypesConfig.chatTypes.find((item) => item.id === chatTypeId && item.enabled) ?? null;
      const defaultContextIds =
        chatContextSettings.chatTypeDefaults.find((item) => item.chatTypeId === chatTypeId)?.defaultContextIds ?? [];
      const defaultContexts = defaultContextIds
        .map((contextId) =>
          chatContextSettings.defaultContexts.find((item) => item.id === contextId && item.enabled),
        )
        .filter((item): item is NonNullable<typeof item> => Boolean(item))
        .map((item) => ({
          id: item.id,
          title: item.title,
          content: item.content,
        }));
      const baseDescription = resolvedChatType?.description?.trim() || '';
      const description = composeResolvedChatTypeDescription(baseDescription, defaultContexts);

      return [
        chatTypeId,
        {
          label: resolvedChatType?.name ?? '개별 채팅유형',
          description: description || baseDescription,
        },
      ] as const;
    }),
  );
}

export function buildParticipantRequestInput(
  baseInput: string,
  participant: {
    name: string;
    model: string;
    prompt: string;
    chatTypeId: string | null;
    role: CodexParticipantRole;
    turn: CodexParticipantTurn;
  },
  participants: Array<{
    name: string;
    model: string;
    prompt: string;
    chatTypeId: string | null;
    role: CodexParticipantRole;
    turn: CodexParticipantTurn;
  }>,
  previousReplies: Array<{ name: string; model: string; text: string }>,
  participantTypeOverride?: { label: string; description: string } | null,
  executionPolicy?: ChatTypeExecutionPolicy | null,
  promptContextRef?: ChatPromptContextRef | null,
) {
  const resolvedPolicy = executionPolicy ?? createDefaultChatTypeExecutionPolicy();
  const lines = [
    baseInput.trim(),
    '',
    `추가 실행 역할: 이번 응답은 ${participant.name} (${participant.model}) 차례입니다.`,
  ];

  if (participant.prompt) {
    lines.push(`역할 메모: ${participant.prompt}`);
  }

  if (participant.turn === 'opening') {
    lines.push(
      resolvedPolicy.mode === 'dispatcher-workers'
        ? '역할 지시: 당신은 이번 대화의 중계 지시자입니다. 지금 턴에서는 작업을 역할·기준·검증 축으로 분해하고, 어떤 Codex가 어떤 관점으로 이어서 처리해야 하는지 분명하게 배분하세요.'
        : '역할 지시: 당신은 이번 대화의 회의 기록자 겸 중재자입니다. 지금 턴에서는 결론을 먼저 확정하지 말고 요청을 재정리한 뒤, 다른 Codex가 이어서 토론할 핵심 논점·검증 기준·확인 포인트를 짧고 분명하게 남기세요.',
    );
  } else if (participant.turn === 'closing') {
    lines.push(
      resolvedPolicy.mode === 'dispatcher-workers'
        ? '역할 지시: 당신은 이번 대화의 중계 지시자입니다. 앞선 Codex 발언을 종합해 최종 결과물, 검토 결과, 남은 리스크와 후속 액션을 보고 형식으로 정리하세요.'
        : '역할 지시: 당신은 이번 대화의 회의 기록자 겸 중재자입니다. 앞선 Codex 발언을 바탕으로 최종 결론과 남은 쟁점을 정리하고, 필요하면 resource·검증 결과·적용 결론 중심으로 보고를 마무리하세요.',
    );
  } else if (participant.turn === 'review' || participant.role === 'reviewer') {
    lines.push(
      '역할 지시: 당신은 최종 검토자입니다. 앞선 논의의 근거, 누락, 충돌, 검증 부족 지점을 점검하고 최종 종합 전에 보완 또는 승인 의견을 분명하게 남기세요.',
    );
  } else if (participant.turn === 'discussion' || participant.role === 'conversation') {
    lines.push(
      resolvedPolicy.mode === 'dispatcher-workers'
        ? '역할 지시: 당신은 실작업자입니다. 배정된 관점에서 구현, 설계, 검증, 반례를 구체적으로 제시하고 필요하면 앞선 지시를 수정 제안하세요.'
        : '역할 지시: 당신은 프리토킹 참가자입니다. 앞선 Codex 의견에 자유롭게 동의·반박·보완하면서 구현, 설계, 검증 관점의 구체 논점을 추가하세요.',
    );
  }

  lines.push(
    `실행 정책: ${resolvedPolicy.mode}`,
    `결과물 보고 정책: ${resolvedPolicy.resourceReportPolicy}`,
    `최종 종합 강제: ${resolvedPolicy.finalSummaryRequired ? '예' : '아니오'}`,
  );

  if (participantTypeOverride?.description) {
    lines.push(
      '',
      `참가자 전용 채팅유형: ${participantTypeOverride.label}`,
      participantTypeOverride.description,
    );
  }

  const promptContextLines = buildPromptContextInstructionLines(promptContextRef);

  if (promptContextLines.length > 0) {
    lines.push('', ...promptContextLines);
  }

  if (participants.length > 1) {
    lines.push(
      `참가자 순서: ${participants.map((entry) => formatParticipantTurnLabel(entry)).join(' -> ')}`,
    );
  }

  if (previousReplies.length > 0) {
    lines.push('', '이전 Codex 발언:');
    previousReplies.forEach((reply, index) => {
      lines.push(`${index + 1}. ${reply.name} (${reply.model})`);
      lines.push(reply.text.trim());
    });
    lines.push('', '위 Codex 발언을 읽고 이어서 답하세요. 필요하면 동의, 반박, 보완, 정리를 명확히 적으세요.');
  } else if (participants.length > 1) {
    lines.push(
      '',
      participant.turn === 'opening'
        ? '중재 시작 턴입니다. 다음 Codex가 바로 이어서 토론할 수 있게 논점을 분명히 남기세요.'
        : '첫 번째 Codex로서 먼저 답한 뒤, 다음 Codex가 이어서 토론할 수 있게 논점을 분명히 남기세요.',
    );
  }

  return lines.filter(Boolean).join('\n');
}

export class ChatService {
  private readonly wss = new WebSocketServer({ noServer: true });

  private readonly clientStates = new Map<WebSocket, ChatSessionState>();
  private readonly sessions = new Map<string, ChatSessionState>();
  private readonly cancelledRequestIds = new Set<string>();
  private readonly unsubscribeRuntimeBroadcast: () => void;
  private readonly unsubscribeNotificationBroadcast: () => void;

  constructor(private readonly logger: FastifyBaseLogger) {
    activeChatService = this;
    activeRuntimeController = {
      getJobDetail: (requestId) => chatRuntimeService.getJobDetail(requestId),
      cancelJob: (requestId) => this.cancelRuntimeJob(requestId),
      removeQueuedJob: (requestId) => this.removeQueuedRuntimeJob(requestId),
    };

    this.unsubscribeRuntimeBroadcast = chatRuntimeService.subscribe((snapshot) => {
      this.broadcastRuntimeSnapshot(snapshot);
    });
    this.unsubscribeNotificationBroadcast = subscribeNotificationMessageChanges((event) => {
      this.broadcastNotificationMessageChange(event);
    });

    this.wss.on('connection', (socket: WebSocket, request: IncomingMessage) => {
      void this.handleConnection(socket, request).catch((error: unknown) => {
        const session = this.clientStates.get(socket);
        this.logger.error(error, 'chat websocket connection initialization failed');
        this.clientStates.delete(socket);
        if (session) {
          session.sockets.delete(socket);
        }
        closeSocketSafely(this.logger, socket, 'failed to close websocket after initialization error');
      });
    });
  }

  attachUpgradeHandler() {
    return (request: IncomingMessage, socket: Socket, head: Buffer) => {
      const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost';
      const url = new URL(request.url ?? '/', origin);

      if (url.pathname !== SOCKET_PATH) {
        socket.destroy();
        return;
      }

      this.wss.handleUpgrade(request, socket, head, (websocket: WebSocket) => {
        this.wss.emit('connection', websocket, request);
      });
    };
  }

  getRuntimeSnapshot(): ChatServiceRuntimeSnapshot {
    let activeRequestCount = 0;
    let queuedRequestCount = 0;
    let activeSocketCount = 0;
    let connectedSessionCount = 0;

    for (const session of this.sessions.values()) {
      activeRequestCount += session.activeRequestCount;
      queuedRequestCount += session.queue.length;

      let sessionHasOpenSocket = false;
      for (const socket of session.sockets) {
        if (socket.readyState === SOCKET_READY_STATE_OPEN) {
          activeSocketCount += 1;
          sessionHasOpenSocket = true;
        }
      }

      if (sessionHasOpenSocket) {
        connectedSessionCount += 1;
      }
    }

    return {
      activeRequestCount,
      queuedRequestCount,
      connectedSessionCount,
      activeSocketCount,
      canAcceptNewRequests: !isRuntimeDraining(),
    };
  }

  close() {
    activeRuntimeController = null;
    if (activeChatService === this) {
      activeChatService = null;
    }
    this.unsubscribeRuntimeBroadcast();
    this.unsubscribeNotificationBroadcast();

    for (const execution of activeChatProcessRegistry.values()) {
      void Promise.resolve(execution.cancel()).catch(() => {
        // noop
      });
    }

    activeChatProcessRegistry.clear();
    chatRuntimeService.clearAll();

    for (const client of this.clientStates.keys()) {
      client.close();
    }

    this.wss.close();
  }

  async recoverInterruptedSessions() {
    if (!(await isCurrentWorkServerSlotActive())) {
      this.logger.info(
        {
          slot: process.env.WORK_SERVER_SLOT?.trim() || null,
        },
        'skip interrupted chat recovery on inactive work-server slot',
      );

      return {
        sessionCount: 0,
        restartedCount: 0,
        requeuedCount: 0,
      };
    }

    const recoverableRequests = await listRecoverableChatConversationRequests();

    if (recoverableRequests.length === 0) {
      return {
        sessionCount: 0,
        restartedCount: 0,
        requeuedCount: 0,
      };
    }

    const requestsBySession = new Map<string, typeof recoverableRequests>();

    for (const item of recoverableRequests) {
      const existing = requestsBySession.get(item.sessionId);

      if (existing) {
        existing.push(item);
      } else {
        requestsBySession.set(item.sessionId, [item]);
      }
    }

    let restartedCount = 0;
    let requeuedCount = 0;

    for (const [sessionId, items] of requestsBySession.entries()) {
      const recoverableItems = items.filter((item) => !shouldClearConversationJobState({
        currentRequestId: item.currentRequestId,
        currentJobStatus: item.currentJobStatus,
        currentStatusUpdatedAt: item.currentStatusUpdatedAt,
        runtimeActive: false,
        request: {
          requestId: item.requestId,
          status: item.status,
          responseMessageId: null,
          responseText: '',
          terminalAt: null,
          updatedAt: item.updatedAt,
        },
      }));

      for (const staleItem of items.filter((item) => !recoverableItems.includes(item))) {
        await upsertChatConversationRequest(staleItem.sessionId, {
          requestId: staleItem.requestId,
          requestOrigin: staleItem.requestOrigin,
          sharedResourceTokenId: staleItem.sharedResourceTokenId,
          parentRequestId: staleItem.parentRequestId,
          status: 'failed',
          statusMessage: '중단된 오래된 요청',
          userText: staleItem.userText,
        });
      }

      if (recoverableItems.length === 0) {
        continue;
      }
      const session = this.getOrCreateSession(sessionId, recoverableItems[0]?.clientId ?? null);
      const primaryItem =
        recoverableItems.find((item) => item.requestId === item.currentRequestId && item.currentJobStatus === 'started') ??
        recoverableItems.find((item) => item.status === 'started') ??
        recoverableItems[0];

      session.clientId = primaryItem?.clientId?.trim() || session.clientId;
      session.context = {
        pageId: null,
        pageTitle: '',
        topMenu: '',
        focusedComponentId: null,
        pageUrl: '',
        chatTypeId: primaryItem?.chatTypeId ?? primaryItem?.lastChatTypeId ?? null,
        chatTypeLabel: primaryItem?.contextLabel ?? '',
        chatTypeDescription: primaryItem?.contextDescription ?? '',
      };

      for (const item of recoverableItems) {
        if (item.status === 'started' && item.requestId !== primaryItem?.requestId) {
          await upsertChatConversationRequest(item.sessionId, {
            requestId: item.requestId,
            requestOrigin: item.requestOrigin,
            sharedResourceTokenId: item.sharedResourceTokenId,
            parentRequestId: item.parentRequestId,
            status: 'failed',
            statusMessage: '중단된 오래된 요청',
            userText: item.userText,
          });
          continue;
        }
        if (item.requestId === primaryItem?.requestId) {
          continue;
        }

        const requestedAtMs = parseRequestedAtMs(item.createdAt);
        session.queue.push({
          requestId: item.requestId,
          text: item.userText,
          mode: 'queue',
          requestedAtMs,
          requestOrigin: item.requestOrigin ?? 'composer',
          sharedResourceTokenId: item.sharedResourceTokenId ?? null,
          parentRequestId: item.parentRequestId ?? null,
          context: session.context ? cloneChatContext(session.context) : null,
        });
        chatRuntimeService.enqueueJob({
          sessionId,
          requestId: item.requestId,
          mode: 'queue',
          text: item.userText,
        });
        chatRuntimeService.appendLog(item.requestId, '워크서버 재기동 후 대기열 복구를 준비합니다.');
        requeuedCount += 1;
      }

      if (!primaryItem) {
        continue;
      }

      restartedCount += 1;
      void this.executeRequest(session, {
        requestId: primaryItem.requestId,
        text: primaryItem.userText,
        mode: 'queue',
        requestedAtMs: parseRequestedAtMs(primaryItem.createdAt),
        requestOrigin: primaryItem.requestOrigin ?? 'composer',
        sharedResourceTokenId: primaryItem.sharedResourceTokenId ?? null,
        parentRequestId: primaryItem.parentRequestId ?? null,
        context: session.context ? cloneChatContext(session.context) : null,
      }).catch((error: unknown) => {
        this.logger.error(
          { error, sessionId: session.sessionId, requestId: primaryItem.requestId },
          'failed to recover interrupted chat request',
        );
      });
    }

    this.logger.info(
      {
        sessionCount: requestsBySession.size,
        restartedCount,
        requeuedCount,
      },
      'recovered interrupted chat sessions after work-server restart',
    );

    return {
      sessionCount: requestsBySession.size,
      restartedCount,
      requeuedCount,
    };
  }

  private getOrCreateSession(sessionId: string, clientId?: string | null) {
    const existing = this.sessions.get(sessionId);

    if (existing) {
      if (clientId?.trim()) {
        existing.clientId = clientId.trim();
      }
      return existing;
    }

    const nextSession: ChatSessionState = {
      sessionId,
      clientId: clientId?.trim() || null,
      sockets: new Set<WebSocket>(),
      lastSeenAt: Date.now(),
      isDeleted: false,
      context: null,
      queue: [],
      activeRequestCount: 0,
      pendingQueueReleaseEventId: null,
      pendingQueueReleaseTimer: null,
      nextEventId: 1,
      eventHistory: [],
      messagePersistenceTail: Promise.resolve(),
      watchedRuntimeRequestId: null,
    };
    this.sessions.set(sessionId, nextSession);
    return nextSession;
  }

  private createSessionEnvelope(session: ChatSessionState, message: ChatOutboundPayload): ChatOutboundMessage {
    return {
      ...message,
      eventId: session.nextEventId++,
      sessionId: session.sessionId,
    };
  }

  private sendEnvelopeToSessionSockets(session: ChatSessionState, envelope: ChatOutboundMessage, errorMessage: string) {
    for (const socket of session.sockets) {
      sendSocketEnvelope(this.logger, socket, envelope, errorMessage);
    }
  }

  private retainEnvelopeForReplay(session: ChatSessionState, envelope: ChatOutboundMessage) {
    if (envelope.type === 'chat:init' || envelope.type === 'chat:status') {
      return;
    }

    session.eventHistory.push(envelope);

    if (session.eventHistory.length > CHAT_SESSION_EVENT_HISTORY_LIMIT) {
      session.eventHistory.splice(0, session.eventHistory.length - CHAT_SESSION_EVENT_HISTORY_LIMIT);
    }
  }

  private persistConversationMessage(session: ChatSessionState, message: ChatMessage) {
    if (session.isDeleted) {
      return Promise.resolve();
    }

    const nextPersistence = session.messagePersistenceTail
      .catch(() => undefined)
      .then(() =>
        appendChatConversationMessage(
          {
            sessionId: session.sessionId,
            clientId: session.clientId,
            title: null,
            contextLabel: session.context?.chatTypeLabel ?? null,
            contextDescription: session.context?.chatTypeDescription ?? null,
            notifyOffline: undefined,
          },
          {
            sessionId: session.sessionId,
            messageId: message.id,
            author: message.author,
            text: message.text,
            timestamp: message.timestamp,
            clientRequestId: message.clientRequestId ?? null,
            parts: message.parts ?? [],
          },
        ),
      );

    session.messagePersistenceTail = nextPersistence.catch((error: unknown) => {
      this.logger.error(error, 'failed to persist chat message');
    });

    return session.messagePersistenceTail;
  }

  private sendToSession(
    session: ChatSessionState,
    message: ChatOutboundPayload,
    options?: {
      skipOfflineNotification?: boolean;
    },
  ) {
    if (session.isDeleted) {
      const normalizedDeletedMessage =
        message.type === 'chat:message'
          ? {
              ...message,
              payload: normalizeStructuredChatMessage(message.payload),
            }
          : message;
      return this.createSessionEnvelope(session, normalizedDeletedMessage);
    }

    const normalizedMessage =
      message.type === 'chat:message'
        ? {
            ...message,
            payload: normalizeStructuredChatMessage(message.payload),
          }
        : message;
    const envelope = this.createSessionEnvelope(session, normalizedMessage);
    this.retainEnvelopeForReplay(session, envelope);

    this.sendEnvelopeToSessionSockets(session, envelope, 'failed to send websocket session envelope');

    if (normalizedMessage.type === 'chat:message') {
      this.persistConversationMessage(session, normalizedMessage.payload);

      if (normalizedMessage.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
        void this.sendOfflineNotificationBestEffort(session, normalizedMessage.payload);
      }
    }

    return envelope;
  }

  private async sendOfflineNotificationBestEffort(session: ChatSessionState, message: ChatMessage) {
    try {
      await this.sendOfflineNotificationIfNeeded(session, message);
    } catch (error: unknown) {
      this.logger.error(error, 'failed to send offline chat notification; request processing will continue');
    }
  }

  private updateMessageInSession(session: ChatSessionState, message: ChatMessage) {
    const normalizedMessage = normalizeStructuredChatMessage(message);
    const envelope = this.createSessionEnvelope(session, {
      type: 'chat:message:update',
      payload: normalizedMessage,
    });
    this.retainEnvelopeForReplay(session, envelope);

    this.sendEnvelopeToSessionSockets(session, envelope, 'failed to send websocket message update envelope');

    // Streaming codex deltas and synthesized activity summaries are transient UI state.
    // Persist only the final chat message / activity rows to avoid long DB tails that
    // can keep a finished request looking "running" until every intermediate update flushes.
    if (shouldPersistMessageUpdate(normalizedMessage)) {
      this.persistConversationMessage(session, normalizedMessage);
    }

    return envelope;
  }

  broadcastRequestUpdate(sessionId: string, request: ChatConversationRequestItem) {
    const normalizedSessionId = sessionId.trim();

    if (!normalizedSessionId) {
      return;
    }

    for (const session of this.sessions.values()) {
      if (session.sessionId !== normalizedSessionId) {
        continue;
      }

      this.sendToSession(session, {
        type: 'chat:request:update',
        payload: request,
      });
    }
  }

  broadcastMessageUpdate(sessionId: string, message: ChatMessage) {
    const normalizedSessionId = sessionId.trim();

    if (!normalizedSessionId) {
      return;
    }

    for (const session of this.sessions.values()) {
      if (session.sessionId !== normalizedSessionId) {
        continue;
      }

      this.updateMessageInSession(session, message);
    }
  }

  private broadcastRuntimeSnapshot(snapshot = chatRuntimeService.getSnapshot()) {
    for (const session of this.sessions.values()) {
      this.sendToSession(session, {
        type: 'chat:runtime',
        payload: snapshot,
      });

      this.pushRuntimeDetail(session);
    }
  }

  private broadcastNotificationMessageChange(event: NotificationMessageChangeEvent) {
    for (const session of this.sessions.values()) {
      const payload =
        event.action === 'deleted'
          ? {
              action: 'deleted' as const,
              itemId: event.id,
            }
          : {
              action: event.action,
              itemId: event.item.id,
              category: event.item.category,
              read: event.item.read,
            };
      const envelope = this.createSessionEnvelope(session, {
        type: 'notification:messages-updated',
        payload,
      });
      this.sendEnvelopeToSessionSockets(session, envelope, 'failed to send notification message update envelope');
    }
  }

  getSessionClientIdMap() {
    return new Map(
      [...this.sessions.entries()].map(([sessionId, session]) => [sessionId, session.clientId?.trim() || null]),
    );
  }

  private pushRuntimeDetail(session: ChatSessionState) {
    const requestId = session.watchedRuntimeRequestId?.trim();

    if (!requestId) {
      return;
    }

    this.sendToSession(session, {
      type: 'chat:runtime:detail',
      payload: chatRuntimeService.getJobDetail(requestId),
    });
  }

  private async cancelRuntimeJob(requestId: string) {
    const execution = activeChatProcessRegistry.get(requestId);
    const detail = chatRuntimeService.getJobDetail(requestId);

    if (!execution && detail.item && detail.terminalStatus == null) {
      chatRuntimeService.appendLog(requestId, '실행 준비 단계에서 취소 요청을 접수했습니다.');
      this.cancelledRequestIds.add(requestId);
      const session = this.findSessionByRequestId(requestId);

      if (session) {
        void upsertChatConversationRequest(session.sessionId, {
          requestId,
          status: 'cancelled',
          statusMessage: '사용자 요청으로 실행 취소를 대기합니다.',
        }).catch((error: unknown) => {
          this.logger.warn(error, 'failed to persist pending chat request cancellation state');
        });
      }

      return true;
    }

    if (!execution) {
      return false;
    }

    chatRuntimeService.appendLog(requestId, '사용자 요청으로 실행 취소를 시도합니다.');
    this.cancelledRequestIds.add(requestId);
    const session = this.findSessionByRequestId(requestId);

    if (session) {
      void upsertChatConversationRequest(session.sessionId, {
        requestId,
        status: 'cancelled',
        statusMessage: '사용자 요청으로 실행 취소를 시도합니다.',
      }).catch((error: unknown) => {
        this.logger.warn(error, 'failed to persist chat request cancellation state');
      });
    }

    try {
      const cancelled = await execution.cancel();

      if (!cancelled && this.cancelledRequestIds.has(requestId)) {
        chatRuntimeService.appendLog(requestId, '취소 신호를 재시도 대기 중입니다.');
        return true;
      }

      return cancelled;
    } catch (error) {
      this.logger.warn(error, 'failed to cancel chat runtime job');
      chatRuntimeService.appendLog(requestId, '실행 취소 요청에 실패했습니다.');
      this.cancelledRequestIds.delete(requestId);
      return false;
    }
  }

  private async removeQueuedRuntimeJob(requestId: string) {
    let removed = false;
    let removedSessionId: string | null = null;

    for (const session of this.sessions.values()) {
      const nextQueue = session.queue.filter((item) => item.requestId !== requestId);

      if (nextQueue.length === session.queue.length) {
        continue;
      }

      session.queue = nextQueue;
      removed = true;
      removedSessionId = session.sessionId;
      this.emitJobState(session, {
        requestId,
        status: 'completed',
        mode: 'queue',
        queueSize: session.queue.length,
        message: '대기열에서 제거됨',
      });
      this.sendToSession(session, {
        type: 'chat:message',
        payload: createMessage('system', '대기열 요청이 관리 화면에서 제거되었습니다.'),
      });
      break;
    }

    if (!removed) {
      return false;
    }

    chatRuntimeService.finishJob(requestId, 'removed');
    if (removedSessionId) {
      await upsertChatConversationRequest(removedSessionId, {
        requestId,
        status: 'removed',
        statusMessage: '대기열에서 제거되었습니다.',
      });
    }
    return true;
  }

  private tryStartNextQueuedRequest(session: ChatSessionState) {
    this.normalizeSessionExecutionState(session);

    if (session.activeRequestCount > 0 || session.queue.length === 0 || session.pendingQueueReleaseEventId !== null) {
      return;
    }

    const nextQueuedRequest = session.queue.shift();

    if (!nextQueuedRequest) {
      return;
    }

    void this.executeRequest(session, nextQueuedRequest).catch((error: unknown) => {
      this.logger.error(error, 'queued chat reply build failed');
      this.sendToSession(session, {
        type: 'chat:error',
        payload: {
          message: '대기 중이던 채팅 요청 처리 중 오류가 발생했습니다.',
        },
      });
    });
  }

  private clearPendingQueueReleaseTimer(session: ChatSessionState) {
    if (session.pendingQueueReleaseTimer !== null) {
      clearTimeout(session.pendingQueueReleaseTimer);
      session.pendingQueueReleaseTimer = null;
    }
  }

  private releaseQueuedRequests(session: ChatSessionState) {
    session.pendingQueueReleaseEventId = null;
    this.clearPendingQueueReleaseTimer(session);
    this.tryStartNextQueuedRequest(session);
  }

  private schedulePendingQueueRelease(session: ChatSessionState) {
    this.clearPendingQueueReleaseTimer(session);
    session.pendingQueueReleaseTimer = setTimeout(() => {
      if (session.pendingQueueReleaseEventId === null) {
        session.pendingQueueReleaseTimer = null;
        return;
      }

      this.releaseQueuedRequests(session);
    }, QUEUE_RELEASE_FALLBACK_DELAY_MS);
  }

  private normalizeSessionExecutionState(session: ChatSessionState) {
    const snapshot = chatRuntimeService.getSnapshot();
    const runtimeSession = snapshot.sessions.find((item) => item.sessionId === session.sessionId) ?? null;
    const runtimeRunningCount = runtimeSession?.runningCount ?? 0;
    const runtimeQueuedIds = new Set(
      snapshot.queued.filter((item) => item.sessionId === session.sessionId).map((item) => item.requestId),
    );

    session.activeRequestCount = runtimeRunningCount;
    session.queue = session.queue.filter((item) => runtimeQueuedIds.has(item.requestId));
  }

  private emitJobState(
    session: ChatSessionState,
    payload: {
      requestId: string;
      status: 'queued' | 'started' | 'completed' | 'failed';
      mode: 'queue' | 'direct';
      queueSize: number;
      message: string;
    },
  ) {
    this.sendToSession(session, {
      type: 'chat:job',
      payload,
    });

    void updateChatConversationJobState(session.sessionId, {
      requestId: payload.requestId,
      status: payload.status,
      message: payload.message,
      queueSize: payload.queueSize,
      clear: false,
    }).catch((error: unknown) => {
      this.logger.error(error, 'failed to persist chat job state');
    });

    const requestStatus =
      payload.status === 'queued'
        ? 'queued'
        : payload.status === 'started'
          ? 'started'
          : payload.status === 'completed'
            ? 'completed'
            : this.cancelledRequestIds.has(payload.requestId)
              ? 'cancelled'
              : 'failed';

    void upsertChatConversationRequest(session.sessionId, {
      requestId: payload.requestId,
      status: requestStatus,
      statusMessage: payload.message,
    }).catch((error: unknown) => {
      this.logger.error(error, 'failed to persist chat request state');
    });
  }

  private findSessionByRequestId(requestId: string) {
    for (const session of this.sessions.values()) {
      if (session.queue.some((item) => item.requestId === requestId)) {
        return session;
      }

      if (
        session.eventHistory.some((event) => {
          if (event.type === 'chat:job') {
            return event.payload.requestId === requestId;
          }

          if (event.type === 'chat:message' || event.type === 'chat:message:update') {
            return event.payload.clientRequestId === requestId;
          }

          return false;
        })
      ) {
        return session;
      }
    }

    return null;
  }

  private async sendOfflineNotificationIfNeeded(session: ChatSessionState, message: ChatMessage) {
    if (!session.clientId?.trim()) {
      return;
    }

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

    const appOrigin = resolveChatContextAppOrigin(session.context);
    const appDomain = resolveChatContextAppDomain(session.context);
    const [conversation, appConfig] = await Promise.all([
      getChatConversation(session.sessionId, session.clientId),
      getAppConfigSnapshot(appOrigin),
    ]);

    if (
      !shouldSendOfflineChatNotification({
        receiveRoomNotifications: appConfig.chat?.receiveRoomNotifications,
        conversationNotifyOffline: conversation?.notifyOffline,
      })
    ) {
      return;
    }

    const requestOwnerClientId = message.clientRequestId?.trim()
      ? (await getChatConversationRequest(session.sessionId, message.clientRequestId).catch(() => null))
          ?.requesterClientId?.trim() || null
      : null;

    if (!requestOwnerClientId) {
      return;
    }

    const preferredNotificationClientIds = await listChatConversationOfflineNotificationClientIds(session.sessionId, {
      keepClientIds: [session.clientId, requestOwnerClientId],
    }).catch(() => []);
    const notificationCandidateClientIds = collectOfflineNotificationClientIds([
      requestOwnerClientId,
      ...preferredNotificationClientIds,
    ]);
    const notificationTargetClientIds = notificationCandidateClientIds.filter((clientId) => {
      const evaluation = evaluateChatClientActiveViewing(clientId, this.sessions.values());
      logChatClientActiveViewingEvaluation(this.logger, clientId, evaluation);
      return !evaluation.isActive;
    });

    if (!notificationTargetClientIds.length) {
      this.logger.info(
        {
          sessionId: session.sessionId,
          messageId: message.id,
          requestOwnerClientId,
          notificationCandidateClientIds,
        },
        'chat offline notification skipped because every target client is actively viewing',
      );
      return;
    }

    const notificationPayload = await this.buildOfflineChatNotificationPayload(
      session,
      message,
      conversation?.title || '현재 채팅방',
    );

    if (!notificationPayload) {
      return;
    }

    await this.createOfflineAppNotificationIfNeeded(notificationPayload, notificationTargetClientIds);

    const result = await sendNotifications(
      {
        title: notificationPayload.title,
        body: notificationPayload.body,
        data: notificationPayload.data,
        threadId: notificationPayload.threadId,
        targetClientIds: notificationTargetClientIds,
        targetAppOrigins: appOrigin ? [appOrigin] : undefined,
        targetAppDomains: appDomain ? [appDomain] : undefined,
      },
      {
        disableIos: true,
      },
    );

    if (!result.web.ok && !result.web.skipped) {
      this.logger.warn(
        {
          sessionId: session.sessionId,
          messageId: message.id,
          targetClientIds: notificationTargetClientIds,
          webPush: result.web,
        },
        'chat webpush delivery reported failures',
      );
    }
  }

  private async createOfflineAppNotificationIfNeeded(
    payload: {
      title: string;
      body: string;
      previewText: string;
      metadata: Record<string, string>;
      linkUrl: string;
    },
    targetClientIds: string[],
  ) {
    await createNotificationMessage({
      title: payload.title,
      body: payload.body,
      category: 'chat',
      source: 'codex-live',
      priority: 'normal',
      metadata: {
        ...payload.metadata,
        targetClientIds,
        linkUrl: payload.linkUrl,
        linkLabel: '채팅 바로 열기',
        previewText: payload.previewText,
      },
    });
  }

  private async buildOfflineChatNotificationPayload(
    session: ChatSessionState,
    message: ChatMessage,
    conversationTitle: string,
  ) {
    const answerText = String(message.text ?? '').trim();

    if (!answerText || isPreparingChatReply(answerText)) {
      return null;
    }

    const linkUrl = buildChatNotificationTargetUrl(session.context, session.sessionId);
    const requests = message.clientRequestId?.trim()
      ? await listChatConversationRequests(session.sessionId, 200).catch(() => [])
      : [];
    const matchingRequest =
      requests.find((request) => request.requestId === message.clientRequestId) ??
      requests.find((request) => request.responseMessageId === message.id) ??
      null;
    const questionText = matchingRequest?.userText?.trim() || '';
    const fallback = createChatNotificationPreview(answerText) || `${conversationTitle} 채팅방에 새 응답이 도착했습니다.`;
    const body = createChatQuestionAnswerNotificationBody({
      questionText,
      answerText,
      fallback,
    });
    const previewText = createChatQuestionOnlyNotificationPreview(questionText, fallback);

    if (!body.trim()) {
      return null;
    }

    const appOrigin = resolveChatContextAppOrigin(session.context);
    const appDomain = resolveChatContextAppDomain(session.context);
    const metadata = {
      category: 'chat',
      sessionId: session.sessionId,
      conversationTitle,
      messageId: String(message.id),
      messageTimestamp: message.timestamp,
      questionText: normalizeNotificationDetailText(questionText) ?? '',
      answerText: normalizeNotificationDetailText(answerText) ?? '',
      targetUrl: linkUrl,
      notificationKey: `chat:${session.sessionId}:${message.id}`,
      type: 'chat-reply',
      suppressIfVisible: 'true',
      appOrigin: normalizeNotificationDetailText(appOrigin) ?? '',
      appDomain: normalizeNotificationDetailText(appDomain) ?? '',
    };

    return {
      title: 'Codex Live 새 메시지',
      body,
      previewText,
      linkUrl,
      threadId: `chat:${session.sessionId}`,
      data: metadata,
      metadata,
    };
  }

  private replaySessionHistory(session: ChatSessionState, lastEventId: number) {
    if (session.isDeleted || !Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) {
      return;
    }

    const pendingEnvelopes = session.eventHistory.filter((envelope) => envelope.eventId > lastEventId);

    for (const envelope of pendingEnvelopes) {
      this.sendEnvelopeToSessionSockets(session, envelope, 'failed to replay websocket session history envelope');
    }
  }

  private async initializeSession(session: ChatSessionState) {
    if (session.isDeleted) {
      return;
    }

    await session.messagePersistenceTail.catch(() => undefined);

    const messages = await listChatConversationMessages(session.sessionId, { limit: 500 });

    const initEnvelope = this.createSessionEnvelope(session, {
      type: 'chat:init',
      payload: {
        messages,
      },
    });

    this.sendEnvelopeToSessionSockets(session, initEnvelope, 'failed to send websocket init envelope');

    const statusEnvelope = this.createSessionEnvelope(session, {
      type: 'chat:status',
      payload: {
        connectedAt: new Date().toISOString(),
      },
    });

    this.sendEnvelopeToSessionSockets(session, statusEnvelope, 'failed to send websocket status envelope');

    this.sendToSession(session, {
      type: 'chat:runtime',
      payload: chatRuntimeService.getSnapshot(),
    });
  }

  private async handleConnection(socket: WebSocket, request: IncomingMessage) {
    const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost';
    const url = new URL(request.url ?? '/', origin);

    if (!(await hasAuthorizedChatSocketAccess(request, url))) {
      closeSocketSafely(this.logger, socket, 'failed to close unauthorized chat websocket session', 1008, 'unauthorized');
      return;
    }

    const requestedSessionId = url.searchParams.get('sessionId')?.trim() || createRequestId();
    const clientId = url.searchParams.get('clientId')?.trim() || null;
    const lastEventIdRaw = Number(url.searchParams.get('lastEventId') ?? 0);
    const lastEventId = Number.isFinite(lastEventIdRaw) && lastEventIdRaw > 0 ? lastEventIdRaw : 0;
    const session = this.getOrCreateSession(requestedSessionId, clientId);

    session.sockets.add(socket);
    session.lastSeenAt = Date.now();
    this.clientStates.set(socket, session);
    trackWebSocketConnectionOpened();

    socket.on('message', (raw: RawData) => {
      this.handleMessage(socket, raw);
    });

    socket.on('close', () => {
      this.clientStates.delete(socket);
      session.sockets.delete(socket);
      trackWebSocketConnectionClosed();
    });

    socket.on('error', (error: Error) => {
      this.logger.error(error, 'chat websocket error');
      this.clientStates.delete(socket);
      session.sockets.delete(socket);
      trackWebSocketConnectionClosed();
    });

    await this.initializeSession(session);
    this.replaySessionHistory(session, lastEventId);
  }

  async forgetSession(sessionId: string) {
    const normalizedSessionId = sessionId.trim();

    if (!normalizedSessionId) {
      return;
    }

    const session = this.sessions.get(normalizedSessionId);
    const runtimeSnapshot = chatRuntimeService.getSnapshot();
    const runtimeRequestIds = new Set(
      [...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent]
        .filter((item) => item.sessionId === normalizedSessionId)
        .map((item) => item.requestId),
    );

    if (session) {
      session.isDeleted = true;
      session.queue = [];
      session.eventHistory = [];
      session.pendingQueueReleaseEventId = null;
      this.clearPendingQueueReleaseTimer(session);
      session.watchedRuntimeRequestId = null;
      session.activeRequestCount = 0;

      for (const socket of session.sockets) {
        this.clientStates.delete(socket);
        closeSocketSafely(this.logger, socket, 'failed to close deleted chat websocket session');
      }
      session.sockets.clear();

      this.sessions.delete(normalizedSessionId);
    }

    for (const requestId of runtimeRequestIds) {
      const detail = chatRuntimeService.getJobDetail(requestId);

      if (detail.availableActions.cancel) {
        try {
          await this.cancelRuntimeJob(requestId);
        } catch {
          // ignore and hard-clear runtime state below
        }
      } else if (detail.availableActions.remove) {
        try {
          await this.removeQueuedRuntimeJob(requestId);
        } catch {
          // ignore and hard-clear runtime state below
        }
      }

      activeChatProcessRegistry.delete(requestId);
      this.cancelledRequestIds.delete(requestId);
    }

    chatRuntimeService.clearSession(normalizedSessionId);
  }

  resetSessionData(sessionId: string) {
    const normalizedSessionId = sessionId.trim();

    if (!normalizedSessionId) {
      return;
    }

    const session = this.sessions.get(normalizedSessionId);

    if (!session) {
      return;
    }

    session.queue = [];
    session.eventHistory = [];
    session.pendingQueueReleaseEventId = null;
    this.clearPendingQueueReleaseTimer(session);
    session.watchedRuntimeRequestId = null;
    session.activeRequestCount = 0;
  }

  private handleMessage(socket: WebSocket, raw: RawData) {
    try {
      const message = JSON.parse(raw.toString()) as ChatInboundMessage;
      const session = this.clientStates.get(socket);

      if (session) {
        session.lastSeenAt = Date.now();
      }

      if (message.type === 'context:update') {
        this.handleContextUpdate(socket, message.payload);
        return;
      }

      if (message.type === 'presence:ping') {
        return;
      }

      if (message.type === 'event:received') {
        const receivedEventId = Math.round(Number(message.payload?.eventId ?? 0));

        if (
          session &&
          Number.isFinite(receivedEventId) &&
          receivedEventId > 0 &&
          session.pendingQueueReleaseEventId !== null &&
          receivedEventId >= session.pendingQueueReleaseEventId
        ) {
          this.releaseQueuedRequests(session);
        }
        return;
      }

      if (message.type === 'message:send') {
        this.rebindSocketToSession(socket, message.payload.sessionId);

        void this.handleUserMessage(
          socket,
          message.payload.text,
          message.payload.requestId,
          message.payload.mode === 'direct' ? 'direct' : 'queue',
          {
            codexModel: message.payload.codexModel ?? null,
            codexParticipants: message.payload.codexParticipants,
            chatTypeId: message.payload.chatTypeId ?? null,
            chatTypeLabel: message.payload.chatTypeLabel,
            chatTypeDescription: message.payload.chatTypeDescription,
            chatTypeBaseDescription: message.payload.chatTypeBaseDescription,
            defaultContextIds: message.payload.defaultContextIds,
            defaultContexts: message.payload.defaultContexts,
            customContextTitle: message.payload.customContextTitle,
            customContextContent: message.payload.customContextContent,
          },
          {
            omitPromptHistory: message.payload.omitPromptHistory === true,
            requestOrigin:
              message.payload.requestOrigin === 'prompt' || message.payload.requestOrigin === 'composer'
                ? message.payload.requestOrigin
                : undefined,
            parentRequestId: message.payload.parentRequestId,
            promptContextRef: normalizeChatPromptContextRef(message.payload.promptContextRef),
          },
        ).catch((error: unknown) => {
          this.logger.error(error, 'chat reply build failed');
          const session = this.clientStates.get(socket);

          if (!session) {
            return;
          }

          this.sendToSession(session, {
            type: 'chat:error',
            payload: {
              message:
                error instanceof Error && error.message.trim()
                  ? error.message.trim()
                  : '채팅 응답을 만드는 중 오류가 발생했습니다.',
            },
          });
        });
        return;
      }

      if (message.type === 'runtime:watch') {
        if (!session) {
          return;
        }

        session.watchedRuntimeRequestId = message.payload?.requestId?.trim() || null;
        this.pushRuntimeDetail(session);
      }
    } catch (error) {
      this.logger.warn(error, 'invalid chat websocket payload');
      const session = this.clientStates.get(socket);

      if (!session) {
        return;
      }

      this.sendToSession(session, {
        type: 'chat:error',
        payload: {
          message: '채팅 메시지를 처리하지 못했습니다.',
        },
      });
    }
  }

  private handleContextUpdate(socket: WebSocket, context: ChatContext) {
    const state = this.clientStates.get(socket);

    if (!state) {
      return;
    }

    const previousFocus = state.context?.focusedComponentId;
    const previousContextLabel = state.context?.chatTypeLabel ?? null;
    const previousContextDescription = state.context?.chatTypeDescription ?? null;
    const previousCodexModel = state.context?.codexModel ?? null;
    state.context = context;
    const nextContextLabel = context.chatTypeLabel ?? null;
    const nextContextDescription = context.chatTypeDescription ?? null;
    const nextCodexModel = context.codexModel ?? null;

    if (
      previousContextLabel !== nextContextLabel ||
      previousContextDescription !== nextContextDescription ||
      previousCodexModel !== nextCodexModel
    ) {
      void updateChatConversationContext(state.sessionId, {
        clientId: state.clientId,
        codexModel: nextCodexModel,
        contextLabel: nextContextLabel,
        contextDescription: nextContextDescription,
      }).catch((error: unknown) => {
        this.logger.error(error, 'failed to persist chat context');
      });
    }

    if (context.focusedComponentId && context.focusedComponentId !== previousFocus) {
      this.sendToSession(state, {
        type: 'chat:message',
        payload: createMessage('codex', `선택 포커스가 ${context.focusedComponentId}로 이동했습니다. 관련 문맥을 반영합니다.`),
      });
    }
  }

  private rebindSocketToSession(socket: WebSocket, targetSessionId?: string | null) {
    const normalizedTargetSessionId = targetSessionId?.trim() || '';
    const currentSession = this.clientStates.get(socket);

    if (!currentSession || !normalizedTargetSessionId || currentSession.sessionId === normalizedTargetSessionId) {
      return currentSession ?? null;
    }

    const targetSession = this.getOrCreateSession(normalizedTargetSessionId, currentSession.clientId);

    currentSession.sockets.delete(socket);
    targetSession.sockets.add(socket);
    targetSession.lastSeenAt = Date.now();
    this.clientStates.set(socket, targetSession);
    return targetSession;
  }

  private async handleUserMessage(
    socket: WebSocket,
    text: string,
    requestId?: string,
    mode: 'queue' | 'direct' = 'queue',
    contextOverride?: Partial<ChatContext> | null,
    requestOptions?: {
      omitPromptHistory?: boolean;
      requestOrigin?: 'composer' | 'prompt';
      sharedResourceTokenId?: string | null;
      parentRequestId?: string | null;
      promptContextRef?: ChatPromptContextRef | null;
    },
  ) {
    const state = this.clientStates.get(socket);

    if (!state) {
      return;
    }

    await this.submitUserMessageToSession(state, text, requestId, mode, contextOverride, requestOptions);
  }

  async submitExternalMessage(
    sessionId: string,
    text: string,
    options?: {
      requestId?: string;
      mode?: 'queue' | 'direct';
      requestOrigin?: 'composer' | 'prompt';
      sharedResourceTokenId?: string | null;
      parentRequestId?: string | null;
      promptContextRef?: ChatPromptContextRef | null;
      omitPromptHistory?: boolean;
      contextOverride?: Partial<ChatContext> | null;
      clientId?: string | null;
    },
  ) {
    const normalizedSessionId = sessionId.trim();
    const trimmedText = text.trim();
    const normalizedContextOverride = sanitizeChatContextOverride(options?.contextOverride);

    if (!normalizedSessionId || !trimmedText) {
      return null;
    }

    const conversation = await getChatConversation(normalizedSessionId, options?.clientId ?? null);
    const parentRequest = options?.parentRequestId?.trim()
      ? await getChatConversationRequest(normalizedSessionId, options.parentRequestId.trim())
      : null;
    const session = this.getOrCreateSession(
      normalizedSessionId,
      conversation?.clientId ?? parentRequest?.requesterClientId ?? options?.clientId ?? null,
    );

    session.clientId =
      conversation?.clientId?.trim() ||
      parentRequest?.requesterClientId?.trim() ||
      options?.clientId?.trim() ||
      session.clientId;

    const baseContext: Partial<ChatContext> = {
      pageId: null,
      pageTitle: '',
      topMenu: '',
      focusedComponentId: null,
      pageUrl: '',
      codexModel:
        Object.prototype.hasOwnProperty.call(normalizedContextOverride ?? {}, 'codexModel')
          ? normalizedContextOverride?.codexModel ?? null
          : conversation?.codexModel ?? null,
      chatTypeId: parentRequest?.chatTypeId ?? conversation?.chatTypeId ?? conversation?.lastChatTypeId ?? null,
      chatTypeLabel: parentRequest?.chatTypeLabel ?? conversation?.contextLabel ?? '',
      chatTypeDescription: conversation?.contextDescription ?? '',
    };

    return this.submitUserMessageToSession(
      session,
      trimmedText,
      options?.requestId,
      options?.mode === 'direct' ? 'direct' : 'queue',
      {
        ...baseContext,
        ...(normalizedContextOverride ?? {}),
      },
      {
        omitPromptHistory: options?.omitPromptHistory,
        requestOrigin: options?.requestOrigin,
        sharedResourceTokenId: options?.sharedResourceTokenId,
        parentRequestId: options?.parentRequestId,
        promptContextRef: options?.promptContextRef,
      },
    );
  }

  private async submitUserMessageToSession(
    state: ChatSessionState,
    text: string,
    requestId?: string,
    mode: 'queue' | 'direct' = 'queue',
    contextOverride?: Partial<ChatContext> | null,
    requestOptions?: {
      omitPromptHistory?: boolean;
      requestOrigin?: 'composer' | 'prompt';
      sharedResourceTokenId?: string | null;
      parentRequestId?: string | null;
      promptContextRef?: ChatPromptContextRef | null;
    },
  ) {
    const trimmed = text.trim();

    if (!trimmed) {
      return null;
    }

    if (isRuntimeDraining()) {
      this.sendToSession(state, {
        type: 'chat:error',
        payload: {
          message: '현재 서버가 배포 전환 중이라 새 AI 요청을 받지 않습니다. 잠시 후 다시 시도해 주세요.',
        },
      });
      return null;
    }

    if (contextOverride) {
      const mergedContext = {
        ...(state.context ?? {
          pageId: null,
          pageTitle: '',
          topMenu: '',
          focusedComponentId: null,
          pageUrl: '',
        }),
        ...contextOverride,
      };
      state.context = (await resolveCodexLiveChatContext(mergedContext, state.sessionId)) ?? mergedContext;
      void updateChatConversationContext(state.sessionId, {
        clientId: state.clientId,
        codexModel: state.context.codexModel ?? null,
        chatTypeId: state.context.chatTypeId ?? null,
        lastChatTypeId: state.context.chatTypeId ?? null,
        contextLabel: state.context.chatTypeLabel ?? null,
        contextDescription: state.context.chatTypeDescription ?? null,
      }).catch((error: unknown) => {
        this.logger.error(error, 'failed to persist chat context from message send');
      });
    }

    this.normalizeSessionExecutionState(state);

    const nextRequestId = requestId?.trim() || createRequestId();
    const requestedAtMs = Date.now();
    const requestOrigin: 'composer' | 'prompt' =
      requestOptions?.requestOrigin === 'prompt' ? 'prompt' : 'composer';

    const request: {
      requestId: string;
      text: string;
      mode: 'queue' | 'direct';
      requestedAtMs: number;
      requestOrigin: 'composer' | 'prompt';
      sharedResourceTokenId: string | null;
      parentRequestId: string | null;
      promptContextRef: ChatPromptContextRef | null;
      omitPromptHistory?: boolean;
      context: ChatContext | null;
    } = {
      requestId: nextRequestId,
      text: trimmed,
      mode,
      requestedAtMs,
      requestOrigin,
      sharedResourceTokenId: requestOptions?.sharedResourceTokenId?.trim() || null,
      parentRequestId: requestOptions?.parentRequestId?.trim() || null,
      promptContextRef: normalizeChatPromptContextRef(requestOptions?.promptContextRef),
      omitPromptHistory: requestOptions?.omitPromptHistory === true,
      context: cloneChatContext(state.context),
    };
    if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) {
      const queuedUserMessage = {
        ...createMessage('user', trimmed, nextRequestId),
        timestamp: formatTime(new Date(requestedAtMs)),
      };

      this.sendToSession(state, {
        type: 'chat:message',
        payload: queuedUserMessage,
      });
      state.queue.push(request);
      chatRuntimeService.enqueueJob({
        sessionId: state.sessionId,
        requestId: nextRequestId,
        mode,
        text: trimmed,
      });
      chatRuntimeService.registerQueuedControl(nextRequestId, {
        remove: () => this.removeQueuedRuntimeJob(nextRequestId),
      });
      this.emitJobState(state, {
        requestId: nextRequestId,
        status: 'queued',
        mode,
        queueSize: state.queue.length,
        message: `대기열 ${state.queue.length}건`,
      });
      void upsertChatConversationRequest(state.sessionId, {
        requestId: nextRequestId,
        chatTypeId: request.context?.chatTypeId ?? state.context?.chatTypeId ?? null,
        chatTypeLabel: request.context?.chatTypeLabel ?? state.context?.chatTypeLabel ?? null,
        requestOrigin: request.requestOrigin,
        sharedResourceTokenId: request.sharedResourceTokenId,
        parentRequestId: request.parentRequestId,
        promptContextRef: request.promptContextRef,
        status: 'queued',
        statusMessage: `대기열 ${state.queue.length}건`,
        userMessageId: queuedUserMessage.id,
        userText: trimmed,
      }).catch((error: unknown) => {
        this.logger.error(error, 'failed to persist queued chat request');
      });
      return nextRequestId;
    }

    void this.executeRequest(state, request).catch((error: unknown) => {
      this.logger.error(error, 'direct chat reply build failed');
      this.sendToSession(state, {
        type: 'chat:error',
        payload: {
          message: '즉시 채팅 요청 처리 중 오류가 발생했습니다.',
        },
      });
    });
    return nextRequestId;
  }

  private async executeRequest(
    session: ChatSessionState,
    request: {
      requestId: string;
      text: string;
      mode: 'queue' | 'direct';
      requestedAtMs: number;
      requestOrigin?: 'composer' | 'prompt';
      sharedResourceTokenId?: string | null;
      parentRequestId?: string | null;
      promptContextRef?: ChatPromptContextRef | null;
      omitPromptHistory?: boolean;
      context: ChatContext | null;
    },
  ) {
    let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed';
    let hasAnnouncedStreaming = false;
    const compactActivityLineMap = new Map<number, string>();
    session.activeRequestCount += 1;
    const existingRequest = await getChatConversationRequest(session.sessionId, request.requestId);
    const isRetryAttempt =
      existingRequest != null
      && !existingRequest.hasResponse
      && (existingRequest.status === 'failed' || existingRequest.status === 'cancelled');
    const hasStoredUserMessage = existingRequest?.userMessageId != null;
    let userMessageId = existingRequest?.userMessageId ?? null;

    if (!hasStoredUserMessage) {
      const userMessage = {
        ...createMessage('user', request.text, request.requestId),
        timestamp: formatTime(new Date(request.requestedAtMs)),
      };
      userMessageId = userMessage.id;
      this.sendToSession(session, {
        type: 'chat:message',
        payload: userMessage,
      });
    }

    await upsertChatConversationRequest(session.sessionId, {
      requestId: request.requestId,
      chatTypeId: request.context?.chatTypeId ?? session.context?.chatTypeId ?? null,
      chatTypeLabel: request.context?.chatTypeLabel ?? session.context?.chatTypeLabel ?? null,
      requestOrigin: request.requestOrigin,
      sharedResourceTokenId: request.sharedResourceTokenId,
      parentRequestId: request.parentRequestId,
      promptContextRef: request.promptContextRef,
      status: request.mode === 'direct' ? 'accepted' : existingRequest?.status ?? 'queued',
      statusMessage: isRetryAttempt ? '재처리 요청 접수' : undefined,
      incrementRetryCount: isRetryAttempt,
      allowTerminalStatusReset: isRetryAttempt,
      userMessageId,
      userText: request.text,
    });

    const appendActivityLineAt = (lineNo: number, line: string) => {
      const normalizedLine = normalizeProgressSummary(line);

      if (!normalizedLine || lineNo <= 0) {
        return;
      }

      const previousLine = compactActivityLineMap.get(lineNo);

      if (previousLine === normalizedLine) {
        return;
      }

      compactActivityLineMap.set(lineNo, normalizedLine);
      const activityLines = Array.from(compactActivityLineMap.entries())
        .sort((left, right) => left[0] - right[0])
        .map(([, activityLine]) => activityLine);

      void appendChatConversationActivityLine(
        session.sessionId,
        request.requestId,
        normalizedLine,
        lineNo,
      ).catch((error: unknown) => {
        this.logger.warn(error, 'failed to persist compact chat activity line');
      });
      this.sendToSession(session, {
        type: 'chat:activity',
        payload: {
          requestId: request.requestId,
          line: normalizedLine,
          lineCount: activityLines.length,
          lineNo,
        },
      });
      const activityMessage = createActivityLogMessage(request.requestId, activityLines);

      if (activityMessage) {
        this.updateMessageInSession(session, activityMessage);
      }
    };
    const appendActivityLine = (line: string) => {
      const compactEntry = createCompactActivityLogEntry(line);

      if (!compactEntry) {
        return;
      }

      appendActivityLineAt(compactEntry.lineNo, compactEntry.line);
    };

    chatRuntimeService.startJob({
      sessionId: session.sessionId,
      requestId: request.requestId,
      mode: request.mode,
      text: request.text,
    });
    chatRuntimeService.registerRunningControl(request.requestId, {
      cancel: () => this.cancelRuntimeJob(request.requestId),
    });
    chatRuntimeService.appendLog(request.requestId, `요청을 처리합니다. mode=${request.mode}`);
    appendActivityLine(`# 상태: 요청을 처리합니다. mode=${request.mode}`);
    this.emitJobState(session, {
      requestId: request.requestId,
      status: 'started',
      mode: request.mode,
      queueSize: session.queue.length,
      message:
        request.mode === 'direct'
          ? '즉시 요청 실행 중'
          : `요청 처리 중${session.queue.length > 0 ? ` · 대기열 ${session.queue.length}건` : ''}`,
    });

    this.sendToSession(session, {
        type: 'chat:message',
        payload: createMessage(
          'system',
          request.mode === 'direct'
            ? '즉시 요청 실행 중입니다.'
            : `요청 실행 중입니다.${session.queue.length > 0 ? ` 남은 대기열 ${session.queue.length}건` : ''}`,
          request.requestId,
        ),
      });

    const stopProgressTimer = () => {
      if (progressTimer !== null) {
        clearTimeout(progressTimer);
        progressTimer = null;
      }
    };
    const progressMessages = buildProgressMessages(request.text);
    let progressIndex = progressMessages.length > 1 ? 1 : 0;
    let lastProgressMessage = progressMessages[0] ?? '';
    let lastMeaningfulProgressSummary = '';
    let progressTimer: ReturnType<typeof setTimeout> | null = null;
    const emitSystemProgressMessage = (nextMessage: string, options?: { appendActivity?: boolean }) => {
      const normalizedMessage = normalizeProgressSummary(nextMessage);

      if (!normalizedMessage || normalizedMessage === lastProgressMessage) {
        return false;
      }

      lastProgressMessage = normalizedMessage;
      chatRuntimeService.appendLog(request.requestId, normalizedMessage);

      if (options?.appendActivity !== false) {
        appendActivityLine(`# 진행: ${normalizedMessage}`);
      }

      this.sendToSession(session, {
        type: 'chat:message',
        payload: createMessage('system', normalizedMessage, request.requestId),
      });
      return true;
    };
    const scheduleFallbackProgressMessage = () => {
      if (progressIndex >= progressMessages.length) {
        return;
      }

      stopProgressTimer();
      progressTimer = setTimeout(() => {
        const nextMessage = progressMessages[progressIndex] ?? '';

        if (!nextMessage || lastMeaningfulProgressSummary) {
          stopProgressTimer();
          return;
        }

        if (emitSystemProgressMessage(nextMessage)) {
          progressIndex += 1;
        }

        stopProgressTimer();
      }, 2200);
    };

    const executionContext = request.context ?? session.context ?? null;
    const codexParticipants = resolveCodexParticipantsForExecution(executionContext);
    const codexExecutionStages = resolveCodexExecutionStages(executionContext, request.mode);
    const shouldShowCodexExecutionPlan = request.mode === 'direct' && codexParticipants.length > 1;
    const planSteps = shouldShowCodexExecutionPlan ? buildCodexExecutionPlanSteps(codexExecutionStages) : [];
    const executorSlots = shouldShowCodexExecutionPlan
      ? buildCodexExecutorActivitySlots(codexParticipants, 12 + planSteps.length + 2)
      : [];
    const planStepsByParticipantKey = new Map<string, CodexExecutionPlanStep[]>();
    const completedPlanParticipantKeys = new Set<string>();
    const executorSlotByParticipantId = new Map<string, CodexExecutorActivitySlot>();
    planSteps.forEach((step) => {
      step.participantKeys?.forEach((participantKey) => {
        const currentSteps = planStepsByParticipantKey.get(participantKey) ?? [];
        currentSteps.push(step);
        planStepsByParticipantKey.set(participantKey, currentSteps);
      });
    });
    executorSlots.forEach((slot) => {
      executorSlotByParticipantId.set(slot.participantId, slot);
    });
    const syncPlanStepCompletion = (step: CodexExecutionPlanStep) => {
      if (!step.participantKeys || step.participantKeys.length === 0) {
        return;
      }

      writePlanStep(
        step,
        step.participantKeys.every((participantKey) => completedPlanParticipantKeys.has(participantKey)),
      );
    };
    const writePlanStep = (step: CodexExecutionPlanStep, completed: boolean) => {
      appendActivityLineAt(step.lineNo, `${completed ? '☑' : '☐'} ${step.label}`);
    };
    const writeExecutorActivity = (participantId: string, status: string) => {
      const slot = executorSlotByParticipantId.get(participantId);

      if (!slot) {
        return;
      }

      appendActivityLineAt(slot.lineNo, `실행기: ${slot.label} · ${normalizeProgressSummary(status) || '대기 중'}`);
    };

    if (shouldShowCodexExecutionPlan) {
      appendActivityLineAt(10, '# 계획: 관리자 계획');
      planSteps.forEach((step) => {
        writePlanStep(step, false);
      });
      appendActivityLineAt(12 + planSteps.length, '# 실행기: 진행 현황');
      executorSlots.forEach((slot) => {
        appendActivityLineAt(slot.lineNo, `실행기: ${slot.label} · 대기 중`);
      });
    }
    const participantTypeOverrides = await resolveCodexParticipantTypeOverrides(
      codexParticipants,
      executionContext,
    );
    const completedParticipantReplies: Array<{ name: string; model: string; text: string }> = [];
    let finalCodexReplyMessage: ChatMessage | null = null;
    let finalUsageSnapshot: ChatConversationRequestUsageSnapshot | null = null;
    let finalTotalTokens: number | null = null;

    try {
      chatRuntimeService.appendLog(request.requestId, '요청 분석을 시작합니다.');
      appendActivityLine('# 진행: 요청 분석을 시작합니다.');
      this.sendToSession(session, {
        type: 'chat:message',
        payload: createMessage('system', progressMessages[0] ?? '요청을 분석하고 있습니다.', request.requestId),
      });
      scheduleFallbackProgressMessage();
      const runParticipant = async (
        participant: (typeof codexParticipants)[number],
        previousReplies: Array<{ name: string; model: string; text: string }>,
      ) => {
        const participantPlanKey = buildCodexPlanParticipantKey(participant);
        const participantPlanSteps = planStepsByParticipantKey.get(participantPlanKey) ?? [];

        if (participantPlanSteps.length > 0) {
          const analysisStep = planSteps.find((step) => step.kind === 'analysis');

          if (analysisStep) {
            writePlanStep(analysisStep, true);
          }
        }

        writeExecutorActivity(participant.id, '응답 준비 중');
        const codexReplyMessage = createMessage('codex', '', request.requestId);
        const participantPrefix = `[${participant.name} · ${participant.model}]`;
        this.updateMessageInSession(session, {
          ...codexReplyMessage,
          text: `${participantPrefix}\n응답을 준비하고 있습니다...`,
          timestamp: resolveResponseTimestamp(request.requestedAtMs),
        });

        const baseExecutionContext = request.context ?? session.context ?? null;
        const participantContextSeed: ChatContext | null = baseExecutionContext
          ? {
              ...baseExecutionContext,
              pageId: baseExecutionContext.pageId ?? null,
              pageTitle: baseExecutionContext.pageTitle ?? '',
              topMenu: baseExecutionContext.topMenu ?? '',
              focusedComponentId: baseExecutionContext.focusedComponentId ?? null,
              pageUrl: baseExecutionContext.pageUrl ?? '',
              codexModel: participant.model,
              ...(participant.chatTypeId
                ? {
                    chatTypeId: participant.chatTypeId,
                  }
                : null),
              ...(participant.defaultContextIds.length > 0
                ? {
                    defaultContextIds: participant.defaultContextIds,
                  }
                : null),
            }
          : {
              pageId: null,
              pageTitle: '',
              topMenu: '',
              focusedComponentId: null,
              pageUrl: '',
              codexModel: participant.model,
              chatTypeId: participant.chatTypeId,
              defaultContextIds: participant.defaultContextIds,
            };
        const participantExecutionContext =
          (await resolveCodexLiveChatContext(participantContextSeed, session.sessionId)) ?? participantContextSeed;
        if (participantExecutionContext) {
          participantExecutionContext.codexModel = participant.model;
        }

        const reply = await buildCodexReply(
          participantExecutionContext,
          buildParticipantRequestInput(
            request.text,
            participant,
            codexParticipants,
            previousReplies,
            participant.chatTypeId ? participantTypeOverrides.get(participant.chatTypeId) : null,
            resolveChatTypeExecutionPolicy(executionContext),
            request.promptContextRef,
          ),
          session.sessionId,
          request.requestId,
          {
            omitPromptHistory: request.omitPromptHistory === true,
            requestedAt: new Date(request.requestedAtMs),
            parentRequestId: request.parentRequestId,
          },
          (partialReply) => {
            stopProgressTimer();
            if (!hasAnnouncedStreaming) {
              hasAnnouncedStreaming = true;
              chatRuntimeService.appendLog(request.requestId, '응답을 실시간으로 전송 중입니다.');
              appendActivityLine('# 진행: 응답을 실시간으로 전송 중입니다.');
            }
            this.updateMessageInSession(session, {
              ...codexReplyMessage,
              text: `${participantPrefix}\n${partialReply}`.trim(),
              timestamp: resolveResponseTimestamp(request.requestedAtMs),
            });
          },
          (activityLine) => {
            appendActivityLine(activityLine);
            const activitySummary = summarizeActivityProgressLine(activityLine);

            if (activitySummary) {
              writeExecutorActivity(participant.id, activitySummary);
            }

            if (activitySummary && activitySummary !== lastMeaningfulProgressSummary) {
              lastMeaningfulProgressSummary = activitySummary;
              stopProgressTimer();
              emitSystemProgressMessage(activitySummary, { appendActivity: false });
            }
          },
          () => this.cancelledRequestIds.has(request.requestId),
        );

        const extracted = extractChatMessageParts(reply.text);
        finalCodexReplyMessage = {
          ...codexReplyMessage,
          text: `${participantPrefix}\n${extracted.strippedText}`.trim(),
          parts: extracted.parts,
          timestamp: resolveResponseTimestamp(request.requestedAtMs),
        };

        this.sendToSession(
          session,
          {
            type: 'chat:message',
            payload: finalCodexReplyMessage,
          },
          {
            skipOfflineNotification: true,
          },
        );

        await this.persistConversationMessage(session, finalCodexReplyMessage);
        finalUsageSnapshot = reply.usageSnapshot;
        finalTotalTokens = reply.totalTokens;

        completedPlanParticipantKeys.add(participantPlanKey);
        participantPlanSteps.forEach((step) => {
          syncPlanStepCompletion(step);
        });

        writeExecutorActivity(participant.id, '처리 완료');

        return {
          name: participant.name,
          model: participant.model,
          text: extracted.strippedText,
        };
      };

      for (const stage of codexExecutionStages) {
        const stagePreviousReplies = [...completedParticipantReplies];

        if (stage.parallel) {
          const results = await Promise.allSettled(
            stage.participants.map((participant) => runParticipant(participant, stagePreviousReplies)),
          );
          const rejected = results.find(
            (result): result is PromiseRejectedResult => result.status === 'rejected',
          );

          if (rejected) {
            throw rejected.reason;
          }

          results.forEach((result) => {
            if (result.status === 'fulfilled') {
              completedParticipantReplies.push(result.value);
            }
          });
          continue;
        }

        for (const participant of stage.participants) {
          const result = await runParticipant(participant, [...completedParticipantReplies]);
          completedParticipantReplies.push(result);
        }
      }
      const finalizeStep = planSteps.find((step) => step.kind === 'finalize');

      if (finalizeStep) {
        writePlanStep(finalizeStep, true);
      }
      chatRuntimeService.appendLog(request.requestId, '응답 생성이 완료되었습니다.');
      appendActivityLine('# 상태: 응답 생성이 완료되었습니다.');

      // Final replies must be durably stored before the request is marked complete,
      // otherwise replay/reload can be stuck on the placeholder text.
      if (!finalCodexReplyMessage) {
        throw new Error('Codex 참가자 응답을 만들지 못했습니다.');
      }
      const completedReplyMessage = finalCodexReplyMessage as ChatMessage;
      await upsertChatConversationRequest(session.sessionId, {
        requestId: request.requestId,
        chatTypeId: request.context?.chatTypeId ?? session.context?.chatTypeId ?? null,
        chatTypeLabel: request.context?.chatTypeLabel ?? session.context?.chatTypeLabel ?? null,
        requestOrigin: request.requestOrigin,
        sharedResourceTokenId: request.sharedResourceTokenId,
        parentRequestId: request.parentRequestId,
        status: 'completed',
        statusMessage: '요청 처리 완료',
        responseMessageId: completedReplyMessage.id,
        responseText: completedReplyMessage.text,
        usageSnapshot: finalUsageSnapshot,
        totalTokens: finalTotalTokens,
      });
      await refreshChatSessionReferenceForRequest({
        sessionId: session.sessionId,
        requestId: request.requestId,
        context: request.context ?? session.context ?? null,
        input: request.text,
        requestStatus: 'completed',
        requestedAt: new Date(request.requestedAtMs),
        completedAt: new Date(),
      });

      const terminalMessageEnvelope = this.sendToSession(session, {
        type: 'chat:message',
        payload: createMessage(
          'system',
          request.mode === 'direct'
            ? '즉시 요청 처리가 끝났습니다.'
            : `요청 처리가 끝났습니다.${session.queue.length > 0 ? ` 대기열 ${session.queue.length}건이 이어집니다.` : ''}`,
          request.requestId,
        ),
      });
      session.pendingQueueReleaseEventId = terminalMessageEnvelope.eventId;
      this.schedulePendingQueueRelease(session);
      this.emitJobState(session, {
        requestId: request.requestId,
        status: 'completed',
        mode: request.mode,
        queueSize: session.queue.length,
        message: '요청 처리 완료',
      });
      chatRuntimeService.finishJob(request.requestId, 'completed');
      await this.sendOfflineNotificationBestEffort(session, completedReplyMessage);
    } catch (error) {
      const finalizeStep = planSteps.find((step) => step.kind === 'finalize');

      if (finalizeStep) {
        writePlanStep(finalizeStep, false);
      }

      const wasCancelled = this.cancelledRequestIds.has(request.requestId);
      terminalStatus = wasCancelled ? 'cancelled' : 'failed';
      const failureResponseText =
        error instanceof ChatRuntimeExecutionError ? error.responseText : '';
      const failureUsageSnapshot =
        error instanceof ChatRuntimeExecutionError ? error.usageSnapshot : null;
      const failureTotalTokens =
        error instanceof ChatRuntimeExecutionError ? error.totalTokens : null;

      if (failureResponseText) {
        const extractedFailureReply = extractChatMessageParts(failureResponseText);
        const failedCodexReplyBase = createMessage('codex', '', request.requestId);
        const failedCodexReplyMessage = {
          ...failedCodexReplyBase,
          text: extractedFailureReply.strippedText,
          parts: extractedFailureReply.parts,
          timestamp: resolveResponseTimestamp(request.requestedAtMs),
        };

        this.sendToSession(
          session,
          {
            type: 'chat:message',
            payload: failedCodexReplyMessage,
          },
          {
            skipOfflineNotification: true,
          },
        );

        await this.persistConversationMessage(session, failedCodexReplyMessage);
        await upsertChatConversationRequest(session.sessionId, {
          requestId: request.requestId,
          requestOrigin: request.requestOrigin,
          sharedResourceTokenId: request.sharedResourceTokenId,
          parentRequestId: request.parentRequestId,
          status: wasCancelled ? 'cancelled' : 'failed',
          statusMessage: wasCancelled ? '요청 실행 중단' : '요청 처리 실패',
          responseMessageId: failedCodexReplyMessage.id,
          responseText: failedCodexReplyMessage.text,
          usageSnapshot: failureUsageSnapshot,
          totalTokens: failureTotalTokens,
        });
      } else if (failureUsageSnapshot || failureTotalTokens != null) {
        await upsertChatConversationRequest(session.sessionId, {
          requestId: request.requestId,
          requestOrigin: request.requestOrigin,
          sharedResourceTokenId: request.sharedResourceTokenId,
          parentRequestId: request.parentRequestId,
          status: wasCancelled ? 'cancelled' : 'failed',
          statusMessage: wasCancelled ? '요청 실행 중단' : '요청 처리 실패',
          usageSnapshot: failureUsageSnapshot,
          totalTokens: failureTotalTokens,
        });
      }

      chatRuntimeService.appendLog(
        request.requestId,
        wasCancelled
          ? '사용자 요청으로 실행이 중단되었습니다.'
          : error instanceof Error
            ? `실행 오류: ${error.message}`
            : '실행 오류가 발생했습니다.',
      );
      appendActivityLine(
        wasCancelled
          ? '# 상태: 사용자 요청으로 실행이 중단되었습니다.'
          : error instanceof Error
            ? `# 오류: ${error.message}`
            : '# 오류: 실행 오류가 발생했습니다.',
      );
      const terminalErrorEnvelope = this.sendToSession(session, {
        type: 'chat:message',
        payload: createMessage(
          'system',
          wasCancelled ? '요청 실행이 중단되었습니다.' : '요청 처리 중 오류가 발생했습니다.',
          request.requestId,
        ),
      });
      session.pendingQueueReleaseEventId = terminalErrorEnvelope.eventId;
      this.schedulePendingQueueRelease(session);
      this.emitJobState(session, {
        requestId: request.requestId,
        status: 'failed',
        mode: request.mode,
        queueSize: session.queue.length,
        message: wasCancelled ? '요청 실행 중단' : '요청 처리 실패',
      });
      await refreshChatSessionReferenceForRequest({
        sessionId: session.sessionId,
        requestId: request.requestId,
        context: request.context ?? session.context ?? null,
        input: request.text,
        requestStatus: wasCancelled ? 'cancelled' : 'failed',
        requestedAt: new Date(request.requestedAtMs),
        completedAt: new Date(),
      });
      throw error;
    } finally {
      stopProgressTimer();
      activeChatProcessRegistry.delete(request.requestId);
      if (chatRuntimeService.getJobDetail(request.requestId).terminalStatus == null) {
        chatRuntimeService.finishJob(request.requestId, terminalStatus);
      }
      this.cancelledRequestIds.delete(request.requestId);
      session.activeRequestCount = Math.max(0, session.activeRequestCount - 1);
      this.tryStartNextQueuedRequest(session);
    }
  }
}
