import { z } from 'zod';
import { db } from '../db/client.js';
import { chatRuntimeService } from './chat-runtime-service.js';
import { parseChatMessageParts, stringifyChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
import { NOTIFICATION_TOKEN_TABLE, WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
import { cleanupNotificationMessagesForStaleTargetClients } from './notification-message-service.js';

export const CHAT_CONVERSATION_TABLE = 'chat_conversations';
export const CHAT_CONVERSATION_MESSAGE_TABLE = 'chat_conversation_messages';
export const CHAT_CONVERSATION_CLIENT_TABLE = 'chat_conversation_clients';
export const CHAT_CONVERSATION_REQUEST_TABLE = 'chat_conversation_requests';
export const CHAT_CONVERSATION_ACTIVITY_TABLE = 'chat_conversation_request_activities';
export const CHAT_CONVERSATION_SOURCE_CHANGE_TABLE = 'chat_conversation_source_changes';
export const CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES = [
  'session_id',
  'request_id',
  'requester_client_id',
  'chat_type_id',
  'chat_type_label',
  'request_origin',
  'parent_request_id',
  'prompt_context_ref',
  'status',
  'status_message',
  'retry_count',
  'user_message_id',
  'user_text',
  'response_message_id',
  'response_text',
  'usage_snapshot',
  'total_tokens',
  'manual_prompt_completed_at',
  'manual_verification_completed_at',
  'answered_at',
  'terminal_at',
  'created_at',
  'updated_at',
] as const;
export const MANAGED_CHAT_SHARE_SESSION_PREFIX = 'chat-share-room-';
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
export const CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH = 10000;
const STALE_CHAT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000;
const PROCESS_RESTART_STALE_GRACE_MS = 10_000;
const CURRENT_SOURCE_PREFIXES = ['src/', 'docs/', 'public/', 'scripts/', 'etc/'] as const;

const conversationPayloadSchema = z.object({
  sessionId: z.string().trim().min(1).max(120),
  clientId: z.string().trim().max(120).nullable().optional(),
  title: z.string().trim().max(200).nullable().optional(),
  draftText: z.string().max(200000).nullable().optional(),
  requestBadgeLabel: z.string().trim().max(120).nullable().optional(),
  codexModel: z.string().trim().max(120).nullable().optional(),
  chatTypeId: z.string().trim().max(120).nullable().optional(),
  lastChatTypeId: z.string().trim().max(120).nullable().optional(),
  generalSectionName: z.string().trim().max(120).nullable().optional(),
  contextLabel: z.string().trim().max(200).nullable().optional(),
  contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).nullable().optional(),
  notifyOffline: z.boolean().optional(),
});

const conversationMessagePayloadSchema = z.object({
  sessionId: z.string().trim().min(1).max(120),
  messageId: z.number().int().positive(),
  author: z.enum(['codex', 'system', 'user']),
  text: z.string().max(200000),
  timestamp: z.string().trim().max(40),
  clientRequestId: z.string().trim().max(120).nullable().optional(),
  parts: z.array(z.custom<ChatMessagePart>()).optional(),
});

export type ChatConversationItem = {
  sessionId: string;
  clientId: string | null;
  draftText: string;
  title: string;
  requestBadgeLabel: string | null;
  codexModel: string | null;
  chatTypeId: string | null;
  lastChatTypeId: string | null;
  generalSectionName: string | null;
  contextLabel: string | null;
  contextDescription: string | null;
  roomScope: Record<string, unknown> | null;
  notifyOffline: boolean;
  hasUnreadResponse: boolean;
  hasPendingAttention: boolean;
  currentRequestId: string | null;
  currentJobStatus: 'queued' | 'started' | 'completed' | 'failed' | null;
  currentJobMessage: string | null;
  currentQueueSize: number;
  currentStatusUpdatedAt: string | null;
  isPendingWork: boolean;
  pendingWorkReason: 'prompt' | 'analysis' | 'design' | null;
  lastRequestPreview: string;
  lastMessagePreview: string;
  lastResponsePreview: string;
  createdAt: string;
  updatedAt: string;
  lastMessageAt: string | null;
};

export type StoredChatMessage = {
  id: number;
  author: 'codex' | 'system' | 'user';
  text: string;
  timestamp: string;
  clientRequestId?: string | null;
  parts?: ChatMessagePart[];
};

export type ChatConversationRequestStatus =
  | 'accepted'
  | 'queued'
  | 'started'
  | 'completed'
  | 'failed'
  | 'cancelled'
  | 'removed';

export type ChatConversationRequestUsageSnapshot = {
  tokenTotals: {
    total: number;
    input: number;
    output: number;
    cached: number;
    reasoning: number;
  };
  totalTokens: number;
};

export class ChatConversationManualCompletionBlockedError extends Error {
  readonly code: 'child-followup-exists';

  constructor(message: string) {
    super(message);
    this.name = 'ChatConversationManualCompletionBlockedError';
    this.code = 'child-followup-exists';
  }
}

export type ChatConversationRequestItem = {
  sessionId: string;
  requestId: string;
  requesterClientId: string | null;
  chatTypeId?: string | null;
  chatTypeLabel?: string;
  requestOrigin: 'composer' | 'prompt' | null;
  sharedResourceTokenId: string | null;
  parentRequestId: string | null;
  promptContextRef: {
    key: 'prompt_parent_question';
    promptTitle: string;
    promptDescription?: string | null;
    parentQuestionText?: string | null;
  } | null;
  status: ChatConversationRequestStatus;
  statusMessage: string | null;
  retryCount: number;
  userMessageId: number | null;
  userText: string;
  responseMessageId: number | null;
  responseText: string;
  usageSnapshot: ChatConversationRequestUsageSnapshot | null;
  totalTokens: number | null;
  hasResponse: boolean;
  canDelete: boolean;
  manualPromptCompletedAt?: string | null;
  manualVerificationCompletedAt?: string | null;
  createdAt: string;
  updatedAt: string;
  answeredAt: string | null;
  terminalAt: string | null;
};

export type ChatConversationActivityLogItem = {
  sessionId: string;
  requestId: string;
  lines: string[];
  updatedAt: string | null;
};

type ChatPromptStepSelectionPatch = {
  stepKey: string;
  stepTitle?: string | null;
  selectedValues: string[];
  freeText?: string | null;
  skipped?: boolean;
};

type ChatPromptSelectionPatch = {
  promptIndex: number;
  promptTitle: string;
  promptSignature: string;
  sourceMessageId?: number;
  selectedValues: string[];
  freeText?: string | null;
  followupText?: string | null;
  stepSelections?: ChatPromptStepSelectionPatch[];
  summaryText?: string | null;
  attachments?: Array<{
    id: string;
    name: string;
    path: string;
    publicUrl: string;
    size: number;
    mimeType: string;
  }>;
};

export type ChatSourceChangeSnapshotItem = {
  id: string;
  sessionId: string;
  clientId: string | null;
  conversationTitle: string;
  chatTypeId: string | null;
  chatTypeLabel: string;
  requestId: string;
  requestTitle: string;
  questionText: string;
  answerText: string;
  status: ChatConversationRequestStatus;
  sourceChangedAt: string;
  updatedAt: string;
  featureTags: string[];
  changedFiles: string[];
  currentSourceFiles: string[];
  diffBlocks: string[];
  hasSourceChanges: boolean;
  reviewStatus: 'reviewed' | 'not-reviewed';
  sourceChangeKind: 'request' | 'verification-group';
  sourceEntryIds: string[];
  conversationDeletedAt: string | null;
};

export type RecoverableChatConversationRequestItem = {
  sessionId: string;
  clientId: string | null;
  chatTypeId: string | null;
  lastChatTypeId: string | null;
  generalSectionName: string | null;
  contextLabel: string | null;
  contextDescription: string | null;
  currentRequestId: string | null;
  currentJobStatus: ChatConversationItem['currentJobStatus'];
  requestId: string;
  requestOrigin: 'composer' | 'prompt' | null;
  sharedResourceTokenId: string | null;
  parentRequestId: string | null;
  status: ChatConversationRequestStatus;
  userText: string;
  createdAt: string;
  updatedAt: string;
  currentStatusUpdatedAt: string | null;
};

export type ChatConversationDetailPage = {
  messages: StoredChatMessage[];
  requests: ChatConversationRequestItem[];
  activityLogs: ChatConversationActivityLogItem[];
  oldestLoadedMessageId: number | null;
  hasOlderMessages: boolean;
};

type ChatConversationRequestStatusPatch = {
  requestId: string;
  status?: ChatConversationRequestStatus;
  userMessageId?: number | null;
  userText?: string | null;
  responseMessageId?: number | null;
  responseText?: string | null;
};

type ChatConversationResponseCandidate = {
  id: number;
  messageId: number;
  author: StoredChatMessage['author'];
  text: string;
  clientRequestId: string | null;
  createdAt: string | null;
};

type ChatConversationClientPreference = {
  sessionId: string;
  clientId: string;
  notifyOffline: boolean;
  lastReadResponseMessageId: number | null;
};

type ChatConversationOfflineNotificationClientRow = {
  clientId: string;
  notifyOffline: boolean;
  hasActivePushRegistration: boolean;
};

function normalizeDateTimeValue(value: unknown) {
  if (value == null) {
    return null;
  }

  if (value instanceof Date) {
    return value.toISOString();
  }

  const normalized = String(value).trim();

  if (!normalized) {
    return null;
  }

  const parsed = new Date(normalized);
  return Number.isNaN(parsed.getTime()) ? normalized : parsed.toISOString();
}

function normalizeChatConversationUsageMetric(value: unknown) {
  const numericValue = Number(value);
  return Number.isFinite(numericValue) ? Math.max(0, Math.round(numericValue)) : 0;
}

function mapChatConversationRequestUsageSnapshot(value: unknown): ChatConversationRequestUsageSnapshot | null {
  const parsedValue =
    typeof value === 'string' && value.trim()
      ? (() => {
          try {
            return JSON.parse(value);
          } catch {
            return null;
          }
        })()
      : value;

  if (!parsedValue || typeof parsedValue !== 'object') {
    return null;
  }

  const snapshot = parsedValue as Partial<ChatConversationRequestUsageSnapshot> & {
    tokenTotals?: Partial<ChatConversationRequestUsageSnapshot['tokenTotals']> | null;
  };
  const tokenTotals: Partial<ChatConversationRequestUsageSnapshot['tokenTotals']> =
    snapshot.tokenTotals && typeof snapshot.tokenTotals === 'object'
      ? snapshot.tokenTotals
      : {};

  return {
    tokenTotals: {
      total: normalizeChatConversationUsageMetric(tokenTotals.total),
      input: normalizeChatConversationUsageMetric(tokenTotals.input),
      output: normalizeChatConversationUsageMetric(tokenTotals.output),
      cached: normalizeChatConversationUsageMetric(tokenTotals.cached),
      reasoning: normalizeChatConversationUsageMetric(tokenTotals.reasoning),
    },
    totalTokens: normalizeChatConversationUsageMetric(snapshot.totalTokens),
  };
}

function parseStringArray(value: unknown) {
  if (typeof value !== 'string') {
    return [] as string[];
  }

  try {
    const parsed = JSON.parse(value) as unknown;

    if (!Array.isArray(parsed)) {
      return [] as string[];
    }

    return parsed
      .map((item) => String(item ?? '').trim())
      .filter(Boolean);
  } catch {
    return [] as string[];
  }
}

function stringifyStringArray(value: string[]) {
  return JSON.stringify(
    Array.from(
      new Set(
        value.map((item) => String(item ?? '').trim()).filter(Boolean),
      ),
    ),
  );
}

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

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

  if (!normalized) {
    return '';
  }

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

function normalizeClientIdSet(clientIds: Iterable<string | null | undefined>) {
  return new Set(
    Array.from(clientIds, (item) => String(item ?? '').trim()).filter(Boolean),
  );
}

export function collectRegisteredNotificationClientIds(
  rows: Array<{
    device_id?: unknown;
    client_id?: unknown;
  }>,
) {
  return new Set(
    rows
      .flatMap((row) => [row.device_id, row.client_id])
      .map((value) => String(value ?? '').trim())
      .filter(Boolean),
  );
}

export function selectStaleOfflineNotificationClientIds(
  rows: ChatConversationOfflineNotificationClientRow[],
  options?: {
    keepClientIds?: Iterable<string | null | undefined>;
  },
) {
  const keepClientIds = normalizeClientIdSet(options?.keepClientIds ?? []);

  return rows
    .filter((row) => {
      if (!row.notifyOffline) {
        return false;
      }

      if (!row.clientId) {
        return false;
      }

      if (keepClientIds.has(row.clientId)) {
        return false;
      }

      return row.hasActivePushRegistration !== true;
    })
    .map((row) => row.clientId);
}

const SOURCE_CHANGE_VERIFICATION_PATTERN = /^\s*\[\[source-change-verification:(.+?)\]\]\s*$/iu;
const PROMPT_PART_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/iu;

type SourceChangeVerificationFeature = {
  key: string;
  label: string;
  entryRefs: Array<{
    sessionId: string;
    requestId: string;
  }>;
};

type SourceChangeVerificationMetadata = {
  version: number;
  features: SourceChangeVerificationFeature[];
};

function parseJsonRecord(value: string) {
  try {
    const parsed = JSON.parse(value) as unknown;
    return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
      ? parsed as Record<string, unknown>
      : null;
  } catch {
    return null;
  }
}

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

  for (const line of lines) {
    const matched = line.match(SOURCE_CHANGE_VERIFICATION_PATTERN);

    if (!matched?.[1]) {
      continue;
    }

    const record = parseJsonRecord(matched[1]);
    if (!record) {
      continue;
    }

    const features = Array.isArray(record.features)
      ? record.features.flatMap((feature) => {
          if (!feature || typeof feature !== 'object' || Array.isArray(feature)) {
            return [];
          }

          const featureRecord = feature as Record<string, unknown>;
          const key = String(featureRecord.key ?? '').trim();
          const label = String(featureRecord.label ?? '').trim();
          const entryRefs = Array.isArray(featureRecord.entryRefs)
            ? featureRecord.entryRefs.flatMap((entryRef) => {
                if (!entryRef || typeof entryRef !== 'object' || Array.isArray(entryRef)) {
                  return [];
                }

                const entryRefRecord = entryRef as Record<string, unknown>;
                const sessionId = String(entryRefRecord.sessionId ?? '').trim();
                const requestId = String(entryRefRecord.requestId ?? '').trim();

                return sessionId && requestId ? [{ sessionId, requestId }] : [];
              })
            : [];

          return key && label && entryRefs.length > 0 ? [{ key, label, entryRefs }] : [];
        })
      : [];

    if (features.length === 0) {
      continue;
    }

    return {
      version: Number(record.version ?? 1) || 1,
      features,
    } satisfies SourceChangeVerificationMetadata;
  }

  return null;
}

function parseSelectedPromptValues(text: string) {
  const selectedValues = new Set<string>();
  const lines = String(text ?? '').split('\n');

  for (const line of lines) {
    const matched = line.match(PROMPT_PART_PATTERN);

    if (!matched?.[1]) {
      continue;
    }

    const record = parseJsonRecord(matched[1]);
    if (!record) {
      continue;
    }

    (Array.isArray(record.selectedValues) ? record.selectedValues : []).forEach((value) => {
      const normalized = String(value ?? '').trim();

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

  return selectedValues;
}

function normalizePromptSelectionValues(values: unknown) {
  return (Array.isArray(values) ? values : [])
    .map((value) => String(value ?? '').trim())
    .filter(Boolean)
    .filter((value, index, items) => items.indexOf(value) === index);
}

export function buildChatPromptTargetSignature(part: Extract<ChatMessagePart, { type: 'prompt' }>) {
  const stepsSignature = (part.steps ?? [])
    .map((step) =>
      [
        step.key,
        step.title,
        step.description ?? '',
        step.multiple ? 'multi' : 'single',
        step.optional ? 'optional' : 'required',
        step.mode ?? '',
        step.freeTextLabel ?? '',
        step.freeTextPlaceholder ?? '',
        ...step.options.map((option) => [option.value, option.label, option.description ?? ''].join('~')),
      ].join('|'),
    )
    .join('||');
  const optionsSignature = part.options
    .map((option) => [option.value, option.label, option.description ?? ''].join('~'))
    .join('|');

  return [
    part.title,
    part.description ?? '',
    part.mode ?? '',
    part.multiple ? 'multi' : 'single',
    part.submitLabel ?? '',
    part.freeTextLabel ?? '',
    part.freeTextPlaceholder ?? '',
    part.responseTemplate ?? '',
    part.currentStepKey ?? '',
    optionsSignature,
    stepsSignature,
  ].join('::');
}

export function applyChatPromptSelectionPatch(
  parts: ChatMessagePart[] | undefined,
  selection: ChatPromptSelectionPatch,
  resolvedAt: string,
) {
  const promptParts = (parts ?? []).flatMap((part, index) => (part.type === 'prompt' ? [{ index, part }] : []));
  const directMatch = promptParts[selection.promptIndex] ?? null;
  const matchedPrompt =
    directMatch &&
    directMatch.part.title === selection.promptTitle &&
    buildChatPromptTargetSignature(directMatch.part) === selection.promptSignature
      ? directMatch
      : promptParts.find(
          ({ part }) =>
            part.title === selection.promptTitle && buildChatPromptTargetSignature(part) === selection.promptSignature,
        ) ?? null;

  if (!matchedPrompt) {
    return null;
  }

  const nextSelectedValues = normalizePromptSelectionValues(selection.selectedValues);
  const stepSelectionMap = new Map(
    (selection.stepSelections ?? []).map((step) => [
      String(step.stepKey ?? '').trim(),
      {
        selectedValues: normalizePromptSelectionValues(step.selectedValues),
      },
    ]),
  );
  const nextParts = [...(parts ?? [])];
  const currentPart = matchedPrompt.part;
  const nextSteps = currentPart.steps?.map((step) => {
    const matchedStep = stepSelectionMap.get(step.key);
    return matchedStep
      ? {
          ...step,
          selectedValues: matchedStep.selectedValues,
        }
      : step;
  });
  const aggregatedStepSelectedValues =
    nextSteps?.flatMap((step) => normalizePromptSelectionValues(step.selectedValues ?? [])).filter(Boolean) ?? [];
  const resolvedSelectedValues = nextSelectedValues.length > 0 ? nextSelectedValues : aggregatedStepSelectedValues;
  const lastStepKey = (selection.stepSelections ?? [])
    .map((step) => String(step.stepKey ?? '').trim())
    .filter(Boolean)
    .at(-1);
  const normalizedSummaryText = String(selection.summaryText ?? '').trim();
  const normalizedFreeText = String(selection.freeText ?? '').trim();
  const normalizedFollowupText = String(selection.followupText ?? '').trim();

  nextParts[matchedPrompt.index] = {
    ...currentPart,
    steps: nextSteps,
    currentStepKey: lastStepKey || currentPart.currentStepKey,
    selectedValues: resolvedSelectedValues,
    readOnly: true,
    resolvedBy: 'user',
    resolvedAt,
    resultText: normalizedSummaryText || normalizedFreeText || normalizedFollowupText || null,
    attachments: Array.isArray(selection.attachments) ? selection.attachments : [],
  };

  return nextParts;
}

function selectChatPromptSelectionMessageCandidate(
  messageRows: Record<string, unknown>[],
  selection: ChatPromptSelectionPatch,
  resolvedAt: string,
) {
  for (const messageRow of messageRows) {
    const nextParts = applyChatPromptSelectionPatch(parseChatMessageParts(messageRow.parts_json), selection, resolvedAt);

    if (!nextParts) {
      continue;
    }

    return {
      messageRow,
      nextParts,
    };
  }

  return null;
}

function normalizePromptSelectionSourceMessageId(selection: ChatPromptSelectionPatch) {
  return Number.isInteger(selection.sourceMessageId) && (selection.sourceMessageId ?? 0) > 0
    ? Math.trunc(selection.sourceMessageId as number)
    : null;
}

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

  if (!normalized) {
    return '';
  }

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

  return normalized.slice(0, maxLength).trimEnd() + '...';
}

function normalizeStoredPromptContextRef(
  value: unknown,
): ChatConversationRequestItem['promptContextRef'] {
  if (!value) {
    return null;
  }

  const parsedValue =
    typeof value === 'string'
      ? (() => {
          try {
            return JSON.parse(value) as unknown;
          } catch {
            return null;
          }
        })()
      : value;

  if (!parsedValue || typeof parsedValue !== 'object' || (parsedValue as { key?: unknown }).key !== 'prompt_parent_question') {
    return null;
  }

  const promptTitle = normalizePromptContextText((parsedValue as { promptTitle?: string }).promptTitle, 500);

  if (!promptTitle) {
    return null;
  }

  return {
    key: 'prompt_parent_question',
    promptTitle,
    promptDescription:
      normalizePromptContextText((parsedValue as { promptDescription?: string | null }).promptDescription, 1000) || null,
    parentQuestionText:
      normalizePromptContextText((parsedValue as { parentQuestionText?: string | null }).parentQuestionText, 1000) || null,
  };
}

export function collectPromptSelectionCandidateRequestIds(
  requestRows: Array<{
    request_id?: unknown;
    parent_request_id?: unknown;
    created_at?: unknown;
  }>,
  parentRequestId: string,
) {
  const normalizedParentRequestId = parentRequestId.trim();

  if (!normalizedParentRequestId) {
    return [];
  }

  const normalizedRows = requestRows
    .map((row, index) => ({
      requestId: String(row.request_id ?? '').trim(),
      parentRequestId: String(row.parent_request_id ?? '').trim(),
      createdAt: normalizeDateTimeValue(row.created_at) ?? '',
      order: index,
    }))
    .filter((row) => row.requestId);

  const childRowsByParentRequestId = new Map<string, typeof normalizedRows>();

  normalizedRows.forEach((row) => {
    const parentId = row.parentRequestId;

    if (!parentId) {
      return;
    }

    const currentRows = childRowsByParentRequestId.get(parentId) ?? [];
    currentRows.push(row);
    childRowsByParentRequestId.set(parentId, currentRows);
  });

  const includedRequestIds = new Set<string>([normalizedParentRequestId]);
  const pendingParentRequestIds = [normalizedParentRequestId];

  while (pendingParentRequestIds.length > 0) {
    const currentParentRequestId = pendingParentRequestIds.shift() ?? '';
    const childRows = childRowsByParentRequestId.get(currentParentRequestId) ?? [];

    childRows.forEach((row) => {
      if (includedRequestIds.has(row.requestId)) {
        return;
      }

      includedRequestIds.add(row.requestId);
      pendingParentRequestIds.push(row.requestId);
    });
  }

  return normalizedRows
    .filter((row) => includedRequestIds.has(row.requestId))
    .sort((left, right) => {
      const createdAtDiff = right.createdAt.localeCompare(left.createdAt);

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

      return right.order - left.order;
    })
    .map((row) => row.requestId);
}

function sanitizeSourceChangeGroupKey(value: string, fallback: string) {
  const normalized = String(value ?? '')
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 24);

  return normalized || fallback;
}

function buildVerificationGroupRequestId(requestId: string, featureKey: string, index: number) {
  const normalizedRequestId = String(requestId ?? '').trim() || 'verification';
  const safeKey = sanitizeSourceChangeGroupKey(featureKey, `group-${index + 1}`);
  const suffix = `::vf:${index + 1}:${safeKey}`;
  const baseLimit = Math.max(1, 120 - suffix.length);
  return `${normalizedRequestId.slice(0, baseLimit)}${suffix}`;
}

function createRequestTitle(userText: string, fallback: string) {
  const compact = createCompactText(userText, 72);
  return compact || fallback;
}

export function hasMeaningfulChatSourceArtifacts(snapshot: {
  changedFiles?: string[] | null;
  currentSourceFiles?: string[] | null;
  diffBlocks?: string[] | null;
}) {
  return [snapshot.changedFiles, snapshot.currentSourceFiles, snapshot.diffBlocks].some((items) =>
    Array.isArray(items) && items.some((item) => typeof item === 'string' && item.trim().length > 0),
  );
}

function extractDiffBlocks(text: string) {
  return Array.from(text.matchAll(/```diff[^\n]*\n([\s\S]*?)```/g))
    .map((match) => match[1]?.trim() ?? '')
    .filter(Boolean);
}

function normalizeWorkspaceFilePath(value: string) {
  const normalized = String(value ?? '')
    .trim()
    .replace(/\\/g, '/')
    .replace(/^file:\/\//, '')
    .replace(/[)>.,]+$/, '')
    .replace(/:\d+(?::\d+)?$/, '');

  if (!normalized) {
    return '';
  }

  const resourceMarker = '/resource/';
  const resourceIndex = normalized.lastIndexOf(resourceMarker);

  if (resourceIndex >= 0) {
    const innerPath = normalized.slice(resourceIndex + resourceMarker.length).replace(/^\/+/, '');
    return innerPath;
  }

  const apiResourceMarker = '/api/chat/resources/';
  const apiResourceIndex = normalized.lastIndexOf(apiResourceMarker);

  if (apiResourceIndex >= 0) {
    return normalized.slice(apiResourceIndex + apiResourceMarker.length).replace(/^\/+/, '');
  }

  const legacyWorkspaceMarker = '/workspace/main-project/';
  const legacyWorkspaceIndex = normalized.lastIndexOf(legacyWorkspaceMarker);

  if (legacyWorkspaceIndex >= 0) {
    return normalized.slice(legacyWorkspaceIndex + legacyWorkspaceMarker.length);
  }

  for (const prefix of CURRENT_SOURCE_PREFIXES) {
    const marker = `/${prefix}`;
    const markerIndex = normalized.lastIndexOf(marker);

    if (markerIndex >= 0) {
      return normalized.slice(markerIndex + 1);
    }

    if (normalized.startsWith(prefix)) {
      return normalized;
    }
  }

  return normalized.replace(/^\/+/, '').replace(/^\.\//, '');
}

function isCurrentSourcePath(path: string) {
  return CURRENT_SOURCE_PREFIXES.some((prefix) => path.startsWith(prefix));
}

function extractChangedFiles(text: string) {
  const matches = Array.from(
    text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm),
  )
    .flatMap((match) => [match[1], match[2], match[3]])
    .filter((value): value is string => Boolean(value));

  return Array.from(
    new Set(
      matches
        .map((item) => normalizeWorkspaceFilePath(item))
        .filter(Boolean),
    ),
  ).slice(0, 60);
}

function extractCurrentSourceFiles(text: string) {
  const textWithoutChatResourcePaths = text
    .replace(/\/api\/chat\/resources\/[^\s)`]+/g, ' ')
    .replace(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g, ' ');

  const diffPathMatches = Array.from(
    text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm),
  )
    .flatMap((match) => [match[1], match[2], match[3]])
    .filter((value): value is string => Boolean(value))
    .map((item) => normalizeWorkspaceFilePath(item))
    .filter((path) => path && isCurrentSourcePath(path));

  const workspacePathMatches = [
    ...(text.match(/\[[^\]]*]\((\/[^)\s]+)\)/g) ?? []).map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')),
    ...(text.match(/\/(?:[^/\s)]+\/)*(?:src|docs|etc|public|scripts)\/[^\s)`]+/g) ?? []),
  ]
    .map((item) => normalizeWorkspaceFilePath(item))
    .filter((path) => path && isCurrentSourcePath(path));

  const directRelativeMatches = (textWithoutChatResourcePaths.match(/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/g) ?? [])
    .map((item) => normalizeWorkspaceFilePath(item))
    .filter((path) => path && isCurrentSourcePath(path));

  return Array.from(new Set([...diffPathMatches, ...workspacePathMatches, ...directRelativeMatches])).slice(0, 60);
}

function deriveFeatureTags(files: string[]) {
  const tags = new Set<string>();

  files.forEach((file) => {
    const segments = file.split('/').filter(Boolean);

    if (segments[0] === 'src' && segments[1] === 'features' && segments[2]) {
      tags.add(`feature:${segments[2]}`);
      return;
    }

    if (segments[0] === 'src' && segments[1] === 'components' && segments[2]) {
      tags.add(`component:${segments[2]}`);
      return;
    }

    if (segments[0] === 'src' && segments[1] === 'widgets' && segments[2]) {
      tags.add(`widget:${segments[2]}`);
      return;
    }

    if (segments[0] === 'docs' && segments[1]) {
      tags.add(`docs:${segments[1]}`);
      return;
    }

    if (segments[0]) {
      tags.add(segments[0]);
    }
  });

  return Array.from(tags);
}

const SOURCE_CHANGE_MENU_RULES: Array<{
  title: string;
  patterns: RegExp[];
}> = [
  {
    title: 'Preview App / 모바일 앱 열기',
    patterns: [
      /^src\/app\/main\/PreviewAppOverlay\.(?:ts|tsx)$/u,
      /^src\/app\/main\/PreviewAppWindow\.(?:ts|tsx)$/u,
      /^src\/app\/main\/previewRuntime\.(?:ts|tsx|js)$/u,
      /^src\/app\/main\/appUpdate\.(?:ts|tsx|js)$/u,
    ],
  },
  {
    title: '리소스 관리 / 리소스 관리',
    patterns: [
      /^src\/app\/main\/ResourceManagementPage\.(?:ts|tsx)$/u,
      /^src\/app\/main\/resourceManagerApi\.(?:ts|tsx)$/u,
      /^etc\/servers\/work-server\/src\/routes\/resource-manager\.(?:ts|js)$/u,
      /^etc\/servers\/work-server\/src\/services\/resource-manager-service\.(?:ts|js)$/u,
    ],
  },
  {
    title: 'Codex Live / 변경 이력',
    patterns: [/^src\/app\/main\/ChatSourceChangesPage\.(?:ts|tsx)$/u],
  },
  {
    title: '앱로그 / 에러 로그',
    patterns: [
      /^src\/app\/main\/errorLogApi\.(?:ts|js)$/u,
      /^src\/app\/main\/mainChatPanel\/ErrorLogViewer\.(?:ts|tsx|js)$/u,
      /^src\/app\/main\/mainChatPanel\/useErrorLogs\.(?:ts|js)$/u,
    ],
  },
  {
    title: '채팅 관리 / 유형 권한 관리',
    patterns: [/^src\/app\/main\/chatTypeAccess\.(?:ts|tsx)$/u],
  },
  {
    title: '채팅 관리 / 공통 문맥 관리',
    patterns: [/^src\/app\/main\/chatContextSettingsAccess\.(?:ts|tsx)$/u],
  },
  {
    title: '작업 / release 검수',
    patterns: [/^src\/features\/planBoard\/ReleaseReviewPage\.(?:ts|tsx)$/u],
  },
  {
    title: '작업 / 작업 요청',
    patterns: [
      /^src\/features\/planBoard\/PlanBoardPage\.(?:ts|tsx)$/u,
      /^etc\/servers\/work-server\/src\/routes\/plan\.(?:ts|js)$/u,
      /^etc\/servers\/work-server\/src\/services\/plan-service\.(?:ts|js)$/u,
      /^etc\/servers\/work-server\/src\/services\/plan-schedule-service\.(?:ts|js)$/u,
    ],
  },
  {
    title: 'Servers / Command',
    patterns: [
      /^src\/features\/serverCommand\/.+\.(?:ts|tsx)$/u,
      /^etc\/servers\/work-server\/src\/routes\/server-command\.(?:ts|js)$/u,
      /^etc\/servers\/work-server\/src\/services\/server-command-service\.(?:ts|js)$/u,
    ],
  },
  {
    title: 'Play / Layout Editor',
    patterns: [
      /^src\/features\/layout\/.+\.(?:ts|tsx)$/u,
      /^src\/views\/play\/LayoutPlaygroundView\.(?:ts|tsx)$/u,
    ],
  },
  {
    title: 'APIs / Components',
    patterns: [/^src\/components\/.+/u],
  },
  {
    title: 'APIs / Widgets',
    patterns: [/^src\/widgets\/.+/u],
  },
  {
    title: 'Codex Live / Codex Live',
    patterns: [
      /^src\/app\/main\/MainChatPanel\.(?:ts|tsx)$/u,
      /^src\/app\/main\/mainChatPanel\/.+/u,
      /^etc\/servers\/work-server\/src\/routes\/chat\.(?:ts|js)$/u,
      /^etc\/servers\/work-server\/src\/services\/chat-(?:message-parts|room-service|service)\.(?:ts|js)$/u,
    ],
  },
];

function resolveDocsScreenTitle(filePath: string) {
  const matched = filePath.match(/^docs\/([^/]+)/u);

  if (!matched?.[1]) {
    return null;
  }

  if (matched[1] === 'project') {
    return 'Docs / 프로젝트 구조';
  }

  return `Docs / ${matched[1]}`;
}

export function inferSourceChangeScreenTitle(
  files: string[],
  fallbackTitle?: string | null,
) {
  const normalizedFiles = Array.from(
    new Set(
      files
        .map((file) => normalizeWorkspaceFilePath(file))
        .map((file) => file.trim())
        .filter(Boolean),
    ),
  );

  for (const rule of SOURCE_CHANGE_MENU_RULES) {
    if (normalizedFiles.some((file) => rule.patterns.some((pattern) => pattern.test(file)))) {
      return rule.title;
    }
  }

  for (const file of normalizedFiles) {
    const docsTitle = resolveDocsScreenTitle(file);

    if (docsTitle) {
      return docsTitle;
    }
  }

  const normalizedFallback = String(fallbackTitle ?? '').trim();
  return normalizedFallback || '새 대화';
}

const PENDING_WORK_ANALYSIS_PATTERNS = [
  /분석/u,
  /검토/u,
  /조사/u,
  /원인/u,
  /파악/u,
  /\banalysis\b/i,
  /\binvestigat(?:e|ion)\b/i,
] as const;

const PENDING_WORK_DESIGN_PATTERNS = [
  /설계/u,
  /프롬프트/u,
  /시안/u,
  /구조/u,
  /방향/u,
  /기획/u,
  /플로우/u,
  /아키텍처/u,
  /\bdesign\b/i,
  /\barchitecture\b/i,
] as const;

const PENDING_WORK_IMPLEMENTATION_PATTERNS = [
  /구현했/u,
  /수정했/u,
  /반영했/u,
  /적용했/u,
  /완료했/u,
  /마무리했/u,
  /배포했/u,
  /검증했/u,
  /빌드.*통과/u,
  /테스트.*통과/u,
  /캡처/u,
  /preview/iu,
  /변경 파일/u,
  /diff/u,
  /\bimplement(?:ed|ation)?\b/i,
  /\bfix(?:ed)?\b/i,
  /\bverified?\b/i,
  /\btested?\b/i,
] as const;

const PENDING_WORK_RESPONSE_HOLD_PATTERNS = [
  /원하시면/u,
  /진행해드릴/u,
  /이어(?:서|가)/u,
  /다음 단계/u,
  /선택/u,
  /옵션/u,
  /후속/u,
  /\bif you want\b/i,
  /\bnext step\b/i,
] as const;

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

function hasPendingWorkPattern(text: string, patterns: readonly RegExp[]) {
  return patterns.some((pattern) => pattern.test(text));
}

function resolvePendingWorkReasonFromText(text: string) {
  if (!text) {
    return null;
  }

  if (hasPendingWorkPattern(text, PENDING_WORK_DESIGN_PATTERNS)) {
    return 'design' as const;
  }

  if (hasPendingWorkPattern(text, PENDING_WORK_ANALYSIS_PATTERNS)) {
    return 'analysis' as const;
  }

  return null;
}

function hasOpenPromptParts(parts: ChatMessagePart[] | undefined) {
  return (parts ?? []).some((part) => {
    if (part.type !== 'prompt' || part.readOnly === true) {
      return false;
    }

    if ((part.selectedValues?.length ?? 0) > 0) {
      return false;
    }

    if ((part.resultText?.trim() ?? '').length > 0) {
      return false;
    }

    if ((part.resolvedAt?.trim() ?? '').length > 0 || part.resolvedBy != null) {
      return false;
    }

    return true;
  });
}

function resolvePendingWorkState(args: {
  requestText?: string | null;
  responseText?: string | null;
  latestCodexParts?: ChatMessagePart[] | undefined;
}) {
  if (hasOpenPromptParts(args.latestCodexParts)) {
    return {
      isPendingWork: true,
      pendingWorkReason: 'prompt' as const,
    };
  }

  const requestText = normalizePendingWorkText(args.requestText);
  const responseText = normalizePendingWorkText(args.responseText);
  const requestReason = resolvePendingWorkReasonFromText(requestText);

  if (!requestReason) {
    return {
      isPendingWork: false,
      pendingWorkReason: null,
    };
  }

  if (hasPendingWorkPattern(responseText, PENDING_WORK_IMPLEMENTATION_PATTERNS)) {
    return {
      isPendingWork: false,
      pendingWorkReason: null,
    };
  }

  if (!responseText) {
    return {
      isPendingWork: true,
      pendingWorkReason: requestReason,
    };
  }

  const responseReason = resolvePendingWorkReasonFromText(responseText);

  if (responseReason || hasPendingWorkPattern(responseText, PENDING_WORK_RESPONSE_HOLD_PATTERNS)) {
    return {
      isPendingWork: true,
      pendingWorkReason: responseReason ?? requestReason,
    };
  }

  return {
    isPendingWork: false,
    pendingWorkReason: null,
  };
}

function isPendingAttentionPromptPart(
  part: NonNullable<ChatMessagePart>,
): part is Extract<ChatMessagePart, { type: 'prompt' }> {
  return (
    part.type === 'prompt'
    && part.readOnly !== true
    && part.resolvedBy == null
    && !(part.resolvedAt?.trim() ?? '')
  );
}

function hasPendingAttentionPromptMessageParts(parts: ChatMessagePart[] | undefined) {
  return (parts ?? []).some((part) => isPendingAttentionPromptPart(part));
}

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

  if (!normalized) {
    return false;
  }

  if (normalized.length > 720) {
    return true;
  }

  return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalized);
}

export function hasPendingAttentionVerificationRequest(
  request: Pick<ChatConversationRequestItem, 'status' | 'responseMessageId' | 'responseText' | 'requestOrigin'>,
  relatedMessages: Pick<StoredChatMessage, 'author' | 'text'>[],
) {
  if (request.responseMessageId != null) {
    return true;
  }

  if (String(request.responseText ?? '').trim().length > 0) {
    return true;
  }

  if (request.status === 'completed' && request.requestOrigin === 'composer') {
    return relatedMessages.some(
      (message) =>
        (message.author === 'codex' || message.author === 'system')
        && hasPendingAttentionVerificationTarget(message.text),
    );
  }

  return false;
}

function hasChildFollowupRequest(
  request: Pick<ChatConversationRequestItem, 'requestId'>,
  childRequestCountByParentId: Map<string, number>,
) {
  return (childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 0;
}

export function isConversationAttentionPending(options: {
  request: ChatConversationRequestItem;
  relatedMessages: StoredChatMessage[];
  childRequestCountByParentId: Map<string, number>;
  promptFollowupCountByParentId: Map<string, number>;
}) {
  const { request, relatedMessages, childRequestCountByParentId, promptFollowupCountByParentId } = options;

  if (request.requestOrigin === 'prompt') {
    return false;
  }

  if (request.status === 'accepted' || request.status === 'queued' || request.status === 'started') {
    return true;
  }

  if (!request.manualPromptCompletedAt) {
    const unresolvedPromptCount = relatedMessages.reduce((count, message) => {
      if (message.author !== 'codex' && message.author !== 'system') {
        return count;
      }

      return count + (message.parts ?? []).filter((part) => isPendingAttentionPromptPart(part)).length;
    }, 0);
    const promptSubmittedCount = promptFollowupCountByParentId.get(request.requestId.trim()) ?? 0;
    const hasOpenPrompt = unresolvedPromptCount > Math.max(0, promptSubmittedCount);

    if (hasOpenPrompt) {
      return true;
    }
  }

  if (request.manualPromptCompletedAt) {
    return false;
  }

  if (hasChildFollowupRequest(request, childRequestCountByParentId)) {
    return false;
  }

  const hasVerificationTarget = hasPendingAttentionVerificationRequest(request, relatedMessages);

  if (!hasVerificationTarget) {
    return false;
  }

  return !request.manualVerificationCompletedAt;
}

async function getConversationPendingAttentionMap(sessionIds: string[]) {
  const normalizedSessionIds = Array.from(new Set(sessionIds.map((item) => item.trim()).filter(Boolean)));

  if (normalizedSessionIds.length === 0) {
    return new Map<string, boolean>();
  }

  const [requestRows, messageRows] = await Promise.all([
    db(CHAT_CONVERSATION_REQUEST_TABLE)
      .select('*')
      .whereIn('session_id', normalizedSessionIds)
      .orderBy('created_at', 'asc')
      .orderBy('request_id', 'asc'),
    db(CHAT_CONVERSATION_MESSAGE_TABLE)
      .select('session_id', 'message_id', 'author', 'text', 'parts_json', 'client_request_id', 'display_timestamp')
      .whereIn('session_id', normalizedSessionIds)
      .andWhere((builder) => {
        applyVisibleConversationMessageCondition(builder);
      })
      .orderBy('created_at', 'asc')
      .orderBy('message_id', 'asc')
      .orderBy('id', 'asc'),
  ]);

  const requestRowsBySession = new Map<string, ChatConversationRequestItem[]>();
  requestRows.forEach((row) => {
    const request = mapRequestRow(row);
    const current = requestRowsBySession.get(request.sessionId) ?? [];
    current.push(request);
    requestRowsBySession.set(request.sessionId, current);
  });

  const messageRowsBySession = new Map<string, StoredChatMessage[]>();
  messageRows.forEach((row) => {
    const message = mapMessageRow(row);
    const sessionId = String(row.session_id ?? '').trim();

    if (!sessionId) {
      return;
    }

    const current = messageRowsBySession.get(sessionId) ?? [];
    current.push(message);
    messageRowsBySession.set(sessionId, current);
  });

  return normalizedSessionIds.reduce<Map<string, boolean>>((result, sessionId) => {
    const requests = requestRowsBySession.get(sessionId) ?? [];
    const messages = messageRowsBySession.get(sessionId) ?? [];
    const childRequestCountByParentId = requests.reduce<Map<string, number>>((map, request) => {
      const parentRequestId = request.parentRequestId?.trim() || '';

      if (parentRequestId) {
        map.set(parentRequestId, (map.get(parentRequestId) ?? 0) + 1);
      }

      return map;
    }, new Map());
    const promptFollowupCountByParentId = requests.reduce<Map<string, number>>((map, request) => {
      if (request.requestOrigin !== 'prompt') {
        return map;
      }

      const parentRequestId = request.parentRequestId?.trim() || '';

      if (parentRequestId) {
        map.set(parentRequestId, (map.get(parentRequestId) ?? 0) + 1);
      }

      return map;
    }, new Map());
    const requestMessagesById = messages.reduce<Map<string, StoredChatMessage[]>>((map, message) => {
      const requestId = message.clientRequestId?.trim() || '';

      if (!requestId) {
        return map;
      }

      const current = map.get(requestId) ?? [];
      current.push(message);
      map.set(requestId, current);
      return map;
    }, new Map());

    result.set(
      sessionId,
      requests.some((request) =>
        isConversationAttentionPending({
          request,
          relatedMessages: requestMessagesById.get(request.requestId.trim()) ?? [],
          childRequestCountByParentId,
          promptFollowupCountByParentId,
        }),
      ),
    );

    return result;
  }, new Map());
}

const CONTEXT_DEPENDENT_REQUEST_PATTERNS = [
  /이전\s*(채팅|대화|문맥)/u,
  /이전\s*요청/u,
  /마지막\s*요청/u,
  /요청내역/u,
  /두\s*단어/u,
  /최근\s*작업\s*(뱃지|badge|라벨)/iu,
  /(?:이어서|이어진|이어가|계속|추가로|연달아|후속|마저)/u,
  /^(?:그리고|그럼|그러면|또|또한|근데|그런데|여기도|여기서도|이것도|그것도|저것도|이거|그거|저거)/u,
  /\b(?:also|continue|continued|follow[\s-]?up|same|again)\b/i,
] as const;

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

function isContextDependentRequestPreview(text: string) {
  const normalized = normalizeRequestPreviewText(text);

  if (!normalized) {
    return false;
  }

  if (CONTEXT_DEPENDENT_REQUEST_PATTERNS.some((pattern) => pattern.test(normalized))) {
    return true;
  }

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

  return false;
}

function buildLatestRequestPreview(
  requests: Array<{ text: string; createdAt: string | null }>,
): { text: string; createdAt: string | null } | null {
  const normalizedRequests = requests
    .map((request) => ({
      text: normalizeRequestPreviewText(request.text),
      createdAt: request.createdAt,
    }))
    .filter((request) => Boolean(request.text));

  const latestRequest = normalizedRequests[0];

  if (!latestRequest) {
    return null;
  }

  if (!isContextDependentRequestPreview(latestRequest.text)) {
    return latestRequest;
  }

  const previousRequest =
    normalizedRequests.slice(1).find((request) => !isContextDependentRequestPreview(request.text)) ??
    normalizedRequests[1] ??
    null;

  if (!previousRequest) {
    return latestRequest;
  }

  return {
    text: `${previousRequest.text} ${latestRequest.text}`.trim(),
    createdAt: latestRequest.createdAt,
  };
}

function isPreviewableConversationMessage(row: { author?: unknown; text?: unknown }) {
  const author = String(row.author ?? '');
  const text = String(row.text ?? '').trim();

  if (!text) {
    return false;
  }

  if (author === 'system' && text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`)) {
    return false;
  }

  return author === 'user' || author === 'codex';
}

const CHAT_CONVERSATION_LIST_CONTEXT_DESCRIPTION_MAX_LENGTH = 240;

function deriveIsolatedChatRoomScopeFromContextDescription(value: string | null | undefined) {
  const normalized = String(value ?? '').trim();

  if (!normalized.includes('## 격리 채팅방 범위')) {
    return null;
  }

  const lines = normalized.split('\n').map((line) => line.trim());
  const sectionStartIndex = lines.findIndex((line) => line === '## 격리 채팅방 범위');

  if (sectionStartIndex < 0) {
    return null;
  }

  const values = new Map<string, string>();

  for (const line of lines.slice(sectionStartIndex + 1)) {
    if (!line.startsWith('- ')) {
      if (line.startsWith('## ')) {
        break;
      }

      continue;
    }

    const separatorIndex = line.indexOf(':');

    if (separatorIndex <= 2) {
      continue;
    }

    const key = line.slice(2, separatorIndex).trim();
    const rawValue = line.slice(separatorIndex + 1).trim();
    values.set(key, rawValue === '없음' ? '' : rawValue);
  }

  const menuTitle = values.get('현재 활성 메뉴')?.trim() ?? '';
  const featureTitle = values.get('현재 기능')?.trim() ?? '';
  const pageUrl = values.get('pageUrl')?.trim() ?? '';

  if (!menuTitle && !featureTitle && !pageUrl) {
    return null;
  }

  return {
    topMenu: values.get('topMenu')?.trim() || 'unknown',
    menuTitle: menuTitle || '현재 메뉴',
    featureTitle: featureTitle || menuTitle || '현재 기능',
    focusedComponentId: values.get('focusedComponentId')?.trim() || null,
    pageUrl,
    selectionSummary: values.get('현재 선택')?.trim() || null,
    selectionIds: (values.get('선택 ID') ?? '')
      .split(',')
      .map((item) => item.trim())
      .filter(Boolean),
    errorSummary: null,
    sourceAppId: null,
    launchedAt: '',
  } satisfies Record<string, unknown>;
}

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

  if (!normalized) {
    return null;
  }

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

  return `${normalized.slice(0, CHAT_CONVERSATION_LIST_CONTEXT_DESCRIPTION_MAX_LENGTH - 3).trimEnd()}...`;
}

function mapConversationRow(row: Record<string, unknown>): ChatConversationItem {
  const contextDescription = row.context_description == null ? null : String(row.context_description);

  return {
    sessionId: String(row.session_id ?? ''),
    clientId: row.client_id == null ? null : String(row.client_id),
    draftText: row.draft_text == null ? '' : String(row.draft_text),
    title: String(row.title ?? '새 대화'),
    requestBadgeLabel: row.request_badge_label == null ? null : String(row.request_badge_label),
    codexModel: row.codex_model == null ? null : String(row.codex_model),
    chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id),
    lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id),
    generalSectionName: row.general_section_name == null ? null : String(row.general_section_name),
    contextLabel: row.context_label == null ? null : String(row.context_label),
    contextDescription,
    roomScope: deriveIsolatedChatRoomScopeFromContextDescription(contextDescription),
    notifyOffline: Boolean(row.notify_offline),
    hasUnreadResponse: Boolean(row.has_unread_response),
    hasPendingAttention: false,
    currentRequestId: row.current_request_id == null ? null : String(row.current_request_id),
    currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'],
    currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message),
    currentQueueSize: Number(row.current_queue_size ?? 0),
    currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
    isPendingWork: false,
    pendingWorkReason: null,
    lastRequestPreview: '',
    lastMessagePreview: String(row.last_message_preview ?? ''),
    lastResponsePreview: '',
    createdAt: normalizeDateTimeValue(row.created_at) ?? '',
    updatedAt: normalizeDateTimeValue(row.updated_at) ?? '',
    lastMessageAt: normalizeDateTimeValue(row.last_message_at),
  };
}

function mapMessageRow(row: Record<string, unknown>): StoredChatMessage {
  return {
    id: Number(row.message_id ?? row.id ?? 0),
    author: String(row.author ?? 'codex') as StoredChatMessage['author'],
    text: String(row.text ?? ''),
    timestamp: String(row.display_timestamp ?? ''),
    clientRequestId: row.client_request_id == null ? null : String(row.client_request_id),
    parts: parseChatMessageParts(row.parts_json),
  };
}

export function isVisibleConversationMessage(message: StoredChatMessage) {
  if (message.author !== 'system') {
    return true;
  }

  return message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
}

function applyVisibleConversationMessageCondition(builder: any) {
  builder.whereNot('author', 'system').orWhere((nestedBuilder: any) => {
    nestedBuilder.where('author', '=', 'system').andWhere('text', 'like', `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n%`);
  });
}

function mapClientPreferenceRow(row: Record<string, unknown>): ChatConversationClientPreference {
  return {
    sessionId: String(row.session_id ?? ''),
    clientId: String(row.client_id ?? ''),
    notifyOffline: Boolean(row.notify_offline),
    lastReadResponseMessageId:
      row.last_read_response_message_id == null ? null : Number(row.last_read_response_message_id),
  };
}

function mapRequestRow(row: Record<string, unknown>): ChatConversationRequestItem {
  const status = String(row.status ?? 'accepted') as ChatConversationRequestStatus;
  const hasResponse = row.response_message_id != null || String(row.response_text ?? '').trim().length > 0;
  const canDelete = !hasResponse && !['queued', 'started', 'completed'].includes(status);
  const requestOrigin = String(row.request_origin ?? '').trim();
  const parentRequestId = String(row.parent_request_id ?? '').trim();

  return {
    sessionId: String(row.session_id ?? ''),
    requestId: String(row.request_id ?? ''),
    requesterClientId: row.requester_client_id == null ? null : String(row.requester_client_id),
    chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id),
    chatTypeLabel: row.chat_type_label == null ? '' : String(row.chat_type_label),
    requestOrigin: requestOrigin === 'prompt' || requestOrigin === 'composer' ? requestOrigin : null,
    sharedResourceTokenId: row.shared_resource_token_id == null ? null : String(row.shared_resource_token_id),
    parentRequestId: parentRequestId || null,
    promptContextRef: normalizeStoredPromptContextRef(row.prompt_context_ref),
    status,
    statusMessage: row.status_message == null ? null : String(row.status_message),
    retryCount:
      row.retry_count == null || row.retry_count === ''
        ? 0
        : Number.isFinite(Number(row.retry_count))
          ? Math.max(0, Math.round(Number(row.retry_count)))
          : 0,
    userMessageId: row.user_message_id == null ? null : Number(row.user_message_id),
    userText: String(row.user_text ?? ''),
    responseMessageId: row.response_message_id == null ? null : Number(row.response_message_id),
    responseText: String(row.response_text ?? ''),
    usageSnapshot: mapChatConversationRequestUsageSnapshot(row.usage_snapshot),
    totalTokens:
      row.total_tokens == null || row.total_tokens === ''
        ? null
        : Number.isFinite(Number(row.total_tokens))
          ? Math.max(0, Math.round(Number(row.total_tokens)))
          : null,
    hasResponse,
    canDelete,
    manualPromptCompletedAt: normalizeDateTimeValue(row.manual_prompt_completed_at),
    manualVerificationCompletedAt: normalizeDateTimeValue(row.manual_verification_completed_at),
    createdAt: normalizeDateTimeValue(row.created_at) ?? '',
    updatedAt: normalizeDateTimeValue(row.updated_at) ?? '',
    answeredAt: normalizeDateTimeValue(row.answered_at),
    terminalAt: normalizeDateTimeValue(row.terminal_at),
  };
}

function mapSourceChangeSnapshotRow(row: Record<string, unknown>): ChatSourceChangeSnapshotItem {
  const sessionId = String(row.session_id ?? '').trim();
  const requestId = String(row.request_id ?? '').trim();
  const changedFiles = parseStringArray(row.changed_files_json);
  const currentSourceFiles = parseStringArray(row.current_source_files_json);

  return {
    id: `${sessionId}:${requestId}`,
    sessionId,
    clientId: row.client_id == null ? null : String(row.client_id),
    conversationTitle: inferSourceChangeScreenTitle(
      [...changedFiles, ...currentSourceFiles],
      String(row.conversation_title ?? '새 대화'),
    ),
    chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id),
    chatTypeLabel: row.chat_type_label == null ? '' : String(row.chat_type_label),
    requestId,
    requestTitle: String(row.request_title ?? requestId),
    questionText: String(row.question_text ?? ''),
    answerText: String(row.answer_text ?? ''),
    status: String(row.status ?? 'completed') as ChatConversationRequestStatus,
    sourceChangedAt: normalizeDateTimeValue(row.source_changed_at) ?? normalizeDateTimeValue(row.answered_at) ?? '',
    updatedAt: normalizeDateTimeValue(row.updated_at) ?? '',
    featureTags: parseStringArray(row.feature_tags_json),
    changedFiles,
    currentSourceFiles,
    diffBlocks: parseStringArray(row.diff_blocks_json),
    hasSourceChanges: Boolean(row.has_source_changes),
    reviewStatus: String(row.review_status ?? 'not-reviewed') === 'reviewed' ? 'reviewed' : 'not-reviewed',
    sourceChangeKind: String(row.source_change_kind ?? 'request') === 'verification-group' ? 'verification-group' : 'request',
    sourceEntryIds: parseStringArray(row.source_entry_ids_json),
    conversationDeletedAt: normalizeDateTimeValue(row.conversation_deleted_at),
  };
}

function buildSourceChangeSnapshotPayload(args: {
  sessionId: string;
  clientId?: string | null;
  conversationTitle?: string | null;
  chatTypeId?: string | null;
  chatTypeLabel?: string | null;
  requestId: string;
  status: ChatConversationRequestStatus;
  questionText: string;
  answerText: string;
  answeredAt?: string | null;
  updatedAt?: string | null;
  reviewStatus?: 'reviewed' | 'not-reviewed';
  sourceChangeKind?: 'request' | 'verification-group';
  sourceEntryIds?: string[];
}) {
  const changedFiles = extractChangedFiles(args.answerText);
  const diffBlocks = extractDiffBlocks(args.answerText);
  const currentSourceFiles = extractCurrentSourceFiles(args.answerText);
  const sourceChangeTitle = inferSourceChangeScreenTitle(
    [...changedFiles, ...currentSourceFiles],
    args.conversationTitle,
  );
  const featureTags = deriveFeatureTags(changedFiles);
  const hasSourceChanges = hasMeaningfulChatSourceArtifacts({
    changedFiles,
    currentSourceFiles,
    diffBlocks,
  });
  const sourceChangedAt = args.answeredAt?.trim() || args.updatedAt?.trim() || new Date().toISOString();

  return {
    session_id: args.sessionId,
    client_id: normalizeClientId(args.clientId),
    request_id: args.requestId,
    conversation_title: sourceChangeTitle,
    chat_type_id: args.chatTypeId?.trim() || null,
    chat_type_label: args.chatTypeLabel?.trim() || null,
    request_title: createRequestTitle(args.questionText, args.requestId),
    question_text: args.questionText,
    answer_text: args.answerText,
    status: args.status,
    answered_at: args.answeredAt?.trim() || null,
    source_changed_at: sourceChangedAt,
    feature_tags_json: stringifyStringArray(featureTags),
    changed_files_json: stringifyStringArray(changedFiles),
    current_source_files_json: stringifyStringArray(currentSourceFiles),
    diff_blocks_json: stringifyStringArray(diffBlocks),
    has_source_changes: hasSourceChanges,
    review_status: args.reviewStatus === 'reviewed' ? 'reviewed' : 'not-reviewed',
    source_change_kind: args.sourceChangeKind === 'verification-group' ? 'verification-group' : 'request',
    source_entry_ids_json: stringifyStringArray(args.sourceEntryIds ?? []),
    conversation_deleted_at: null,
    updated_at: db.fn.now(),
  };
}

function normalizeClientId(clientId?: string | null) {
  return clientId?.trim() || null;
}

function getTimeValue(value: string | null | undefined) {
  if (!value) {
    return 0;
  }

  const timestamp = new Date(value).getTime();
  return Number.isFinite(timestamp) ? timestamp : 0;
}

function isRuntimeRequestActive(requestId?: string | null) {
  const normalizedRequestId = requestId?.trim() || null;

  if (!normalizedRequestId) {
    return false;
  }

  const detail = chatRuntimeService.getJobDetail(normalizedRequestId);
  return detail.item != null && detail.terminalStatus == null;
}

function isTerminalRequestStatus(status: ChatConversationRequestStatus | null | undefined) {
  return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'removed';
}

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

function hasStoredRequestResponse(request: {
  responseMessageId?: number | null;
  responseText?: string | null;
}) {
  const normalizedResponseText = String(request.responseText ?? '').trim();

  if (normalizedResponseText.length > 0) {
    return !isPreparingChatReplyText(normalizedResponseText);
  }

  return request.responseMessageId != null;
}

function isConversationRequestActive(
  conversation: {
    current_request_id?: unknown;
    current_job_status?: unknown;
  } | null | undefined,
  requestId?: string | null,
) {
  const normalizedRequestId = requestId?.trim() || null;

  if (!normalizedRequestId) {
    return false;
  }

  const currentRequestId = String(conversation?.current_request_id ?? '').trim() || null;
  const currentJobStatus = String(conversation?.current_job_status ?? '').trim();

  if (currentRequestId !== normalizedRequestId) {
    return false;
  }

  return currentJobStatus === 'queued' || currentJobStatus === 'started';
}

function resolveCurrentProcessStartedAtMs(nowMs = Date.now()) {
  const uptimeMs = Math.max(0, Math.floor(process.uptime() * 1000));
  return Math.max(0, nowMs - uptimeMs);
}

function shouldFailStaleInProgressRequest(params: {
  currentRequestId?: string | null;
  currentJobStatus?: ChatConversationItem['currentJobStatus'];
  currentStatusUpdatedAt?: string | null;
  runtimeActive?: boolean;
  nowMs?: number;
  processStartedAtMs?: number;
  request:
    | {
        requestId?: string | null;
        status?: ChatConversationRequestStatus | null;
        responseMessageId?: number | null;
        responseText?: string | null;
        terminalAt?: string | null;
        updatedAt?: string | null;
      }
    | null
    | undefined;
}) {
  const requestStatus = params.request?.status ?? null;
  const hasStoredResponse = hasStoredRequestResponse(params.request ?? {});
  const runtimeActive = params.runtimeActive === true;
  const currentRequestId = params.currentRequestId?.trim() || null;
  const requestId = params.request?.requestId?.trim() || null;
  const currentJobStatus = params.currentJobStatus ?? null;
  const nowMs = Number.isFinite(params.nowMs) ? Number(params.nowMs) : Date.now();
  const processStartedAtMs = Number.isFinite(params.processStartedAtMs)
    ? Number(params.processStartedAtMs)
    : resolveCurrentProcessStartedAtMs(nowMs);
  const isInProgressRequest =
    (requestStatus === 'accepted' || requestStatus === 'queued' || requestStatus === 'started')
    && !hasStoredResponse
    && !isTerminalRequestStatus(requestStatus);
  const isCurrentTrackedRequest =
    Boolean(currentRequestId)
    && Boolean(requestId)
    && currentRequestId === requestId
    && (currentJobStatus === 'queued' || currentJobStatus === 'started');
  const isDetachedInProgressRequest =
    Boolean(requestId)
    && !isCurrentTrackedRequest
    && (requestStatus === 'queued' || requestStatus === 'started');
  const lastUpdatedAt = isCurrentTrackedRequest
    ? Math.max(
        getTimeValue(params.currentStatusUpdatedAt),
        getTimeValue(params.request?.updatedAt),
      )
    : getTimeValue(params.request?.updatedAt);

  if (!requestId || !isInProgressRequest || runtimeActive || lastUpdatedAt <= 0) {
    return false;
  }

  if (
    processStartedAtMs > 0 &&
    lastUpdatedAt <= processStartedAtMs - PROCESS_RESTART_STALE_GRACE_MS &&
    (isCurrentTrackedRequest || isDetachedInProgressRequest)
  ) {
    return true;
  }

  return (
    nowMs - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS &&
    (isCurrentTrackedRequest || isDetachedInProgressRequest)
  );
}

function hasConversationMetadata(
  conversation: {
    title?: unknown;
    draft_text?: unknown;
    request_badge_label?: unknown;
    chat_type_id?: unknown;
    last_chat_type_id?: unknown;
    general_section_name?: unknown;
    context_label?: unknown;
    context_description?: unknown;
    current_request_id?: unknown;
    current_job_status?: unknown;
  } | null | undefined,
) {
  return [
    conversation?.title,
    conversation?.draft_text,
    conversation?.request_badge_label,
    conversation?.chat_type_id,
    conversation?.last_chat_type_id,
    conversation?.general_section_name,
    conversation?.context_label,
    conversation?.context_description,
    conversation?.current_request_id,
    conversation?.current_job_status,
  ].some((value) => String(value ?? '').trim().length > 0);
}

export function isManagedChatShareSessionId(sessionId: string | null | undefined) {
  return String(sessionId ?? '').trim().startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX);
}

export function normalizeStaleRequestItem(
  item: ChatConversationRequestItem,
  conversation: {
    current_request_id?: unknown;
    current_job_status?: unknown;
    current_status_updated_at?: unknown;
  } | null | undefined,
  options?: {
    runtimeActive?: boolean;
    nowMs?: number;
    processStartedAtMs?: number;
  },
) {
  const runtimeActive = options?.runtimeActive ?? isRuntimeRequestActive(item.requestId);

  if (
    shouldClearConversationJobState({
      currentRequestId: String(conversation?.current_request_id ?? ''),
      currentJobStatus:
        conversation?.current_job_status == null
          ? null
          : String(conversation.current_job_status) as ChatConversationItem['currentJobStatus'],
      currentStatusUpdatedAt:
        conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at),
      runtimeActive,
      nowMs: options?.nowMs,
      processStartedAtMs: options?.processStartedAtMs,
      request: item,
    }) || shouldFailStaleInProgressRequest({
      currentRequestId: String(conversation?.current_request_id ?? ''),
      currentJobStatus:
        conversation?.current_job_status == null
          ? null
          : String(conversation.current_job_status) as ChatConversationItem['currentJobStatus'],
      currentStatusUpdatedAt:
        conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at),
      runtimeActive,
      nowMs: options?.nowMs,
      processStartedAtMs: options?.processStartedAtMs,
      request: item,
    })
  ) {
    return {
      ...item,
      status: 'failed' as const,
      statusMessage: '중단된 오래된 요청',
      canDelete: true,
      terminalAt: item.terminalAt ?? item.updatedAt,
    };
  }

  return item;
}

async function reconcileStaleConversationRequests(
  sessionId: string,
  rows: Record<string, unknown>[],
  conversation: {
    current_request_id?: unknown;
    current_job_status?: unknown;
    current_status_updated_at?: unknown;
  } | null | undefined,
) {
  const normalizedSessionId = sessionId.trim();
  const normalizedItems = rows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation));
  const staleRequestIds = normalizedItems
    .flatMap((item, index) => {
      const persistedStatus = String(rows[index]?.status ?? '').trim();
      return item.status === 'failed' && (persistedStatus === 'accepted' || persistedStatus === 'queued' || persistedStatus === 'started')
        ? [item.requestId.trim()]
        : [];
    })
    .filter(Boolean);

  if (staleRequestIds.length > 0) {
    await db(CHAT_CONVERSATION_REQUEST_TABLE)
      .where({ session_id: normalizedSessionId })
      .whereIn('request_id', staleRequestIds)
      .whereIn('status', ['accepted', 'queued', 'started'])
      .update({
        status: 'failed',
        status_message: '중단된 오래된 요청',
        terminal_at: db.fn.now(),
        updated_at: db.fn.now(),
      });
  }

  return normalizedItems;
}

export function shouldClearConversationJobState(params: {
  currentRequestId?: string | null;
  currentJobStatus?: ChatConversationItem['currentJobStatus'];
  currentStatusUpdatedAt?: string | null;
  runtimeActive?: boolean;
  nowMs?: number;
  processStartedAtMs?: number;
  request:
    | {
        requestId?: string | null;
        status?: ChatConversationRequestStatus | null;
        responseMessageId?: number | null;
        responseText?: string | null;
        terminalAt?: string | null;
        updatedAt?: string | null;
      }
    | null
    | undefined;
}) {
  const currentJobStatus = params.currentJobStatus ?? null;
  const requestStatus = params.request?.status ?? null;
  const hasStoredResponse = hasStoredRequestResponse(params.request ?? {});

  if (!currentJobStatus) {
    return false;
  }

  const currentRequestId = params.currentRequestId?.trim() || null;

  if (!currentRequestId) {
    return true;
  }

  const requestId = params.request?.requestId?.trim() || null;

  if (!requestId || requestId !== currentRequestId) {
    return false;
  }

  const runtimeActive = params.runtimeActive === true;

  return (
    (requestStatus != null && requestStatus !== 'completed' && isTerminalRequestStatus(requestStatus)) ||
    hasStoredResponse ||
    shouldFailStaleInProgressRequest({
      currentRequestId,
      currentJobStatus,
      currentStatusUpdatedAt: params.currentStatusUpdatedAt,
      runtimeActive,
      nowMs: params.nowMs,
      processStartedAtMs: params.processStartedAtMs,
      request: params.request,
    })
  );
}

function getRequestStatusRank(status: ChatConversationRequestStatus | null | undefined) {
  switch (status) {
    case 'accepted':
      return 0;
    case 'queued':
      return 1;
    case 'started':
      return 2;
    case 'completed':
    case 'failed':
    case 'cancelled':
    case 'removed':
      return 3;
    default:
      return -1;
  }
}

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

export function mergeChatConversationRequestStatus(
  currentStatus: ChatConversationRequestStatus | null | undefined,
  incomingStatus: ChatConversationRequestStatus | null | undefined,
  options?: {
    allowTerminalStatusReset?: boolean;
  },
): ChatConversationRequestStatus {
  const normalizedCurrent = currentStatus ?? null;
  const normalizedIncoming = incomingStatus ?? null;

  if (!normalizedCurrent && !normalizedIncoming) {
    return 'accepted';
  }

  if (!normalizedCurrent) {
    return normalizedIncoming ?? 'accepted';
  }

  if (!normalizedIncoming) {
    return normalizedCurrent;
  }

  if (
    isTerminalRequestStatus(normalizedCurrent)
    && !isTerminalRequestStatus(normalizedIncoming)
    && options?.allowTerminalStatusReset !== true
  ) {
    return normalizedCurrent;
  }

  if (!isTerminalRequestStatus(normalizedCurrent) && isTerminalRequestStatus(normalizedIncoming)) {
    return normalizedIncoming;
  }

  return getRequestStatusRank(normalizedIncoming) >= getRequestStatusRank(normalizedCurrent)
    ? normalizedIncoming
    : normalizedCurrent;
}

export function buildChatConversationRequestPatchFromMessage(message: {
  id: number;
  author: StoredChatMessage['author'];
  text: string;
  clientRequestId?: string | null;
}): ChatConversationRequestStatusPatch | null {
  const normalizedRequestId = message.clientRequestId?.trim() || null;

  if (!normalizedRequestId) {
    return null;
  }

  if (message.author === 'user') {
    return {
      requestId: normalizedRequestId,
      status: 'accepted',
      userMessageId: message.id,
      userText: message.text,
    };
  }

  if (message.author === 'codex') {
    return {
      requestId: normalizedRequestId,
      status: 'started',
      responseMessageId: message.id,
      responseText: message.text,
    };
  }

  return null;
}

export function selectChatConversationResponseCandidate(
  request: {
    requestId: string;
    createdAt: string;
    responseMessageId?: number | null;
  },
  nextRequest: {
    createdAt: string;
  } | undefined,
  messages: ChatConversationResponseCandidate[],
) {
  const normalizedRequestId = request.requestId.trim();

  if (!normalizedRequestId) {
    return null;
  }

  const directMatches = messages.filter(
    (message) => message.author === 'codex' && message.clientRequestId?.trim() === normalizedRequestId,
  );

  if (directMatches.length > 0) {
    return directMatches.at(-1) ?? null;
  }

  if (request.responseMessageId != null) {
    const responseMessageMatch = messages.find(
      (message) => message.author === 'codex' && message.messageId === request.responseMessageId,
    );

    if (responseMessageMatch) {
      return responseMessageMatch;
    }
  }

  const requestCreatedAt = getTimeValue(request.createdAt);
  const nextRequestCreatedAt = getTimeValue(nextRequest?.createdAt);
  const windowMatches = messages.filter((message) => {
    if (message.author !== 'codex') {
      return false;
    }

    const linkedRequestId = message.clientRequestId?.trim() || null;

    if (linkedRequestId && linkedRequestId !== normalizedRequestId) {
      return false;
    }

    const createdAt = getTimeValue(message.createdAt);

    if (requestCreatedAt > 0 && createdAt > 0 && createdAt < requestCreatedAt) {
      return false;
    }

    if (nextRequestCreatedAt > 0 && createdAt > 0 && createdAt >= nextRequestCreatedAt) {
      return false;
    }

    return Boolean(String(message.text ?? '').trim());
  });

  return windowMatches.at(-1) ?? null;
}

async function getLatestPreviewableMessageMap(sessionIds: string[]) {
  const normalizedSessionIds = sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean);

  if (normalizedSessionIds.length === 0) {
    return new Map<string, { text: string; createdAt: string | null }>();
  }

  const rows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
    .select('session_id', 'author', 'text', 'created_at')
    .whereIn('session_id', normalizedSessionIds)
    .whereIn('author', ['user', 'codex'])
    .orderBy('session_id', 'asc')
    .orderBy('created_at', 'desc')
    .orderBy('id', 'desc');

  const messageMap = new Map<string, { text: string; createdAt: string | null }>();

  for (const row of rows) {
    const sessionId = String(row.session_id ?? '').trim();

    if (!sessionId || messageMap.has(sessionId) || !isPreviewableConversationMessage(row)) {
      continue;
    }

    messageMap.set(sessionId, {
      text: String(row.text ?? ''),
      createdAt: normalizeDateTimeValue(row.created_at),
    });
  }

  return messageMap;
}

async function getLatestRequestPreviewMap(sessionIds: string[]) {
  const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)));

  if (normalizedSessionIds.length === 0) {
    return new Map<string, { text: string; createdAt: string | null }>();
  }

  const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .select('session_id', 'user_text', 'created_at', 'status', 'request_id')
    .whereIn('session_id', normalizedSessionIds)
    .whereNot('status', 'removed')
    .orderBy('session_id', 'asc')
    .orderBy('created_at', 'desc')
    .orderBy('request_id', 'desc');

  const requestMap = new Map<string, { text: string; createdAt: string | null }>();
  const requestRowsBySession = new Map<string, Array<{ text: string; createdAt: string | null }>>();
  const completedSessionIds = new Set<string>();

  for (const row of rows) {
    const sessionId = String(row.session_id ?? '').trim();
    const userText = String(row.user_text ?? '').trim();

    if (!sessionId || completedSessionIds.has(sessionId) || !userText) {
      continue;
    }

    const requestRows = requestRowsBySession.get(sessionId) ?? [];
    requestRows.push({
      text: userText,
      createdAt: normalizeDateTimeValue(row.created_at),
    });

    if (requestRows.length >= 5) {
      completedSessionIds.add(sessionId);
    }

    requestRowsBySession.set(sessionId, requestRows);

    if (completedSessionIds.size >= normalizedSessionIds.length) {
      break;
    }
  }

  for (const sessionId of normalizedSessionIds) {
    const preview = buildLatestRequestPreview(requestRowsBySession.get(sessionId) ?? []);

    if (!preview) {
      continue;
    }

    requestMap.set(sessionId, preview);
  }

  return requestMap;
}

async function getLatestResponsePreviewMap(sessionIds: string[]) {
  const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)));

  if (normalizedSessionIds.length === 0) {
    return new Map<string, { text: string; createdAt: string | null }>();
  }

  const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .select('session_id', 'response_text', 'answered_at', 'updated_at', 'status', 'request_id')
    .whereIn('session_id', normalizedSessionIds)
    .whereNot('status', 'removed')
    .orderBy('session_id', 'asc')
    .orderByRaw('COALESCE(answered_at, updated_at, created_at) desc')
    .orderBy('request_id', 'desc');

  const responseMap = new Map<string, { text: string; createdAt: string | null }>();

  for (const row of rows) {
    const sessionId = String(row.session_id ?? '').trim();
    const responseText = String(row.response_text ?? '').trim();

    if (!sessionId || responseMap.has(sessionId) || !responseText) {
      continue;
    }

    responseMap.set(sessionId, {
      text: responseText,
      createdAt: normalizeDateTimeValue(row.answered_at ?? row.updated_at),
    });
  }

  return responseMap;
}

function resolveConversationPreviewOverride(
  mapped: ChatConversationItem,
  latestMessage: { text: string; createdAt: string | null } | undefined,
  latestRequest: { text: string; createdAt: string | null } | undefined,
) {
  const latestMessageTime = getTimeValue(latestMessage?.createdAt);
  const latestRequestTime = getTimeValue(latestRequest?.createdAt);

  if (latestRequest && latestRequestTime > latestMessageTime) {
    return {
      ...mapped,
      lastMessagePreview: createPreview(latestRequest.text),
      lastMessageAt: latestRequest.createdAt,
    };
  }

  if (latestMessage) {
    return {
      ...mapped,
      lastMessagePreview: createPreview(latestMessage.text),
      lastMessageAt: latestMessage.createdAt,
    };
  }

  return mapped;
}

async function getLatestResponseMessageIdMap(sessionIds: string[]) {
  const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)));

  if (normalizedSessionIds.length === 0) {
    return new Map<string, number>();
  }

  const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .select('session_id', 'response_message_id')
    .whereIn('session_id', normalizedSessionIds)
    .whereNotNull('response_message_id')
    .orderBy('session_id', 'asc')
    .orderBy('response_message_id', 'desc');

  const responseMap = new Map<string, number>();

  for (const row of rows) {
    const sessionId = String(row.session_id ?? '').trim();
    const responseMessageId =
      row.response_message_id == null ? null : Number(row.response_message_id);

    if (!sessionId || responseMessageId == null || responseMap.has(sessionId)) {
      continue;
    }

    responseMap.set(sessionId, responseMessageId);
  }

  return responseMap;
}

async function getLatestCodexPromptPartsMap(sessionIds: string[]) {
  const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)));

  if (normalizedSessionIds.length === 0) {
    return new Map<string, ChatMessagePart[]>();
  }

  const rows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
    .select('session_id', 'parts_json', 'created_at', 'message_id')
    .whereIn('session_id', normalizedSessionIds)
    .andWhere('author', 'codex')
    .orderBy('session_id', 'asc')
    .orderBy('created_at', 'desc')
    .orderBy('message_id', 'desc');

  const promptPartMap = new Map<string, ChatMessagePart[]>();

  for (const row of rows) {
    const sessionId = String(row.session_id ?? '').trim();

    if (!sessionId || promptPartMap.has(sessionId)) {
      continue;
    }

    const parts = parseChatMessageParts(row.parts_json);

    if ((parts ?? []).some((part) => part.type === 'prompt')) {
      promptPartMap.set(sessionId, parts ?? []);
    }
  }

  return promptPartMap;
}

async function getLatestResponseMessageId(sessionId: string) {
  const responseMap = await getLatestResponseMessageIdMap([sessionId]);
  return responseMap.get(sessionId.trim()) ?? null;
}

export async function ensureChatConversationTables() {
  const hasConversationTable = await db.schema.hasTable(CHAT_CONVERSATION_TABLE);

  if (!hasConversationTable) {
    await db.schema.createTable(CHAT_CONVERSATION_TABLE, (table) => {
      table.string('session_id', 120).primary();
      table.string('client_id', 120).nullable().index();
      table.string('title', 200).notNullable().defaultTo('새 대화');
      table.text('draft_text').notNullable().defaultTo('');
      table.string('request_badge_label', 120).nullable();
      table.string('codex_model', 120).nullable();
      table.string('chat_type_id', 120).nullable();
      table.string('last_chat_type_id', 120).nullable();
      table.string('general_section_name', 120).nullable();
      table.string('context_label', 200).nullable();
      table.text('context_description').nullable();
      table.boolean('notify_offline').notNullable().defaultTo(false);
      table.string('current_request_id', 120).nullable();
      table.string('current_job_status', 40).nullable();
      table.text('current_job_message').nullable();
      table.integer('current_queue_size').notNullable().defaultTo(0);
      table.timestamp('current_status_updated_at', { useTz: true }).nullable();
      table.text('last_message_preview').notNullable().defaultTo('');
      table.timestamp('last_message_at', { useTz: true }).nullable();
      table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
      table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
    });
  }

  const requiredConversationColumns: Array<[string, (table: any) => void]> = [
    ['client_id', (table) => table.string('client_id', 120).nullable().index()],
    ['title', (table) => table.string('title', 200).notNullable().defaultTo('새 대화')],
    ['draft_text', (table) => table.text('draft_text').notNullable().defaultTo('')],
    ['request_badge_label', (table) => table.string('request_badge_label', 120).nullable()],
    ['codex_model', (table) => table.string('codex_model', 120).nullable()],
    ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()],
    ['last_chat_type_id', (table) => table.string('last_chat_type_id', 120).nullable()],
    ['general_section_name', (table) => table.string('general_section_name', 120).nullable()],
    ['context_label', (table) => table.string('context_label', 200).nullable()],
    ['context_description', (table) => table.text('context_description').nullable()],
    ['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)],
    ['current_request_id', (table) => table.string('current_request_id', 120).nullable()],
    ['current_job_status', (table) => table.string('current_job_status', 40).nullable()],
    ['current_job_message', (table) => table.text('current_job_message').nullable()],
    ['current_queue_size', (table) => table.integer('current_queue_size').notNullable().defaultTo(0)],
    ['current_status_updated_at', (table) => table.timestamp('current_status_updated_at', { useTz: true }).nullable()],
    ['last_message_preview', (table) => table.text('last_message_preview').notNullable().defaultTo('')],
    ['last_message_at', (table) => table.timestamp('last_message_at', { useTz: true }).nullable()],
    ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
    ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
  ];

  for (const [columnName, createColumn] of requiredConversationColumns) {
    const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_TABLE, columnName);

    if (!hasColumn) {
      await db.schema.alterTable(CHAT_CONVERSATION_TABLE, (table) => {
        createColumn(table);
      });
    }
  }

  const hasClientTable = await db.schema.hasTable(CHAT_CONVERSATION_CLIENT_TABLE);

  if (!hasClientTable) {
    await db.schema.createTable(CHAT_CONVERSATION_CLIENT_TABLE, (table) => {
      table.increments('id').primary();
      table.string('session_id', 120).notNullable().index();
      table.string('client_id', 120).notNullable().index();
      table.boolean('notify_offline').notNullable().defaultTo(false);
      table.bigInteger('last_read_response_message_id').nullable();
      table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
      table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
      table.unique(['session_id', 'client_id']);
    });
  }

  const requiredClientColumns: Array<[string, (table: any) => void]> = [
    ['session_id', (table) => table.string('session_id', 120).notNullable().index()],
    ['client_id', (table) => table.string('client_id', 120).notNullable().index()],
    ['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)],
    ['last_read_response_message_id', (table) => table.bigInteger('last_read_response_message_id').nullable()],
    ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
    ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
  ];

  for (const [columnName, createColumn] of requiredClientColumns) {
    const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_CLIENT_TABLE, columnName);

    if (!hasColumn) {
      await db.schema.alterTable(CHAT_CONVERSATION_CLIENT_TABLE, (table) => {
        createColumn(table);
      });
    }
  }

  const hasMessageTable = await db.schema.hasTable(CHAT_CONVERSATION_MESSAGE_TABLE);

  if (!hasMessageTable) {
    await db.schema.createTable(CHAT_CONVERSATION_MESSAGE_TABLE, (table) => {
      table.increments('id').primary();
      table.string('session_id', 120).notNullable().index();
      table.bigInteger('message_id').notNullable();
      table.string('author', 20).notNullable();
      table.text('text').notNullable();
      table.text('parts_json').notNullable().defaultTo('[]');
      table.string('display_timestamp', 40).notNullable().defaultTo('');
      table.string('client_request_id', 120).nullable();
      table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
      table.unique(['session_id', 'message_id']);
    });
  }

  const requiredMessageColumns: Array<[string, (table: any) => void]> = [
    ['session_id', (table) => table.string('session_id', 120).notNullable().index()],
    ['message_id', (table) => table.bigInteger('message_id').notNullable()],
    ['author', (table) => table.string('author', 20).notNullable().defaultTo('codex')],
    ['text', (table) => table.text('text').notNullable().defaultTo('')],
    ['parts_json', (table) => table.text('parts_json').notNullable().defaultTo('[]')],
    ['display_timestamp', (table) => table.string('display_timestamp', 40).notNullable().defaultTo('')],
    ['client_request_id', (table) => table.string('client_request_id', 120).nullable()],
    ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
  ];

  for (const [columnName, createColumn] of requiredMessageColumns) {
    const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_MESSAGE_TABLE, columnName);

    if (!hasColumn) {
      await db.schema.alterTable(CHAT_CONVERSATION_MESSAGE_TABLE, (table) => {
        createColumn(table);
      });
    }
  }

  const hasRequestTable = await db.schema.hasTable(CHAT_CONVERSATION_REQUEST_TABLE);

  if (!hasRequestTable) {
    await db.schema.createTable(CHAT_CONVERSATION_REQUEST_TABLE, (table) => {
      table.increments('id').primary();
      table.string('session_id', 120).notNullable().index();
      table.string('request_id', 120).notNullable();
      table.string('requester_client_id', 200).nullable();
      table.string('chat_type_id', 120).nullable();
      table.string('chat_type_label', 200).nullable();
      table.string('request_origin', 40).nullable();
      table.string('shared_resource_token_id', 120).nullable().index();
      table.string('parent_request_id', 120).nullable();
      table.text('prompt_context_ref').nullable();
      table.string('status', 40).notNullable().defaultTo('accepted');
      table.text('status_message').nullable();
      table.integer('retry_count').notNullable().defaultTo(0);
      table.bigInteger('user_message_id').nullable();
      table.text('user_text').notNullable().defaultTo('');
      table.bigInteger('response_message_id').nullable();
      table.text('response_text').notNullable().defaultTo('');
      table.text('usage_snapshot').nullable();
      table.integer('total_tokens').nullable();
      table.timestamp('manual_prompt_completed_at', { useTz: true }).nullable();
      table.timestamp('manual_verification_completed_at', { useTz: true }).nullable();
      table.timestamp('answered_at', { useTz: true }).nullable();
      table.timestamp('terminal_at', { useTz: true }).nullable();
      table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
      table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
      table.unique(['session_id', 'request_id']);
    });
  }

  const requiredRequestColumns: Array<[string, (table: any) => void]> = [
    ['session_id', (table) => table.string('session_id', 120).notNullable().index()],
    ['request_id', (table) => table.string('request_id', 120).notNullable()],
    ['requester_client_id', (table) => table.string('requester_client_id', 200).nullable()],
    ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()],
    ['chat_type_label', (table) => table.string('chat_type_label', 200).nullable()],
    ['request_origin', (table) => table.string('request_origin', 40).nullable()],
    ['shared_resource_token_id', (table) => table.string('shared_resource_token_id', 120).nullable().index()],
    ['parent_request_id', (table) => table.string('parent_request_id', 120).nullable()],
    ['prompt_context_ref', (table) => table.text('prompt_context_ref').nullable()],
    ['status', (table) => table.string('status', 40).notNullable().defaultTo('accepted')],
    ['status_message', (table) => table.text('status_message').nullable()],
    ['retry_count', (table) => table.integer('retry_count').notNullable().defaultTo(0)],
    ['user_message_id', (table) => table.bigInteger('user_message_id').nullable()],
    ['user_text', (table) => table.text('user_text').notNullable().defaultTo('')],
    ['response_message_id', (table) => table.bigInteger('response_message_id').nullable()],
    ['response_text', (table) => table.text('response_text').notNullable().defaultTo('')],
    ['usage_snapshot', (table) => table.text('usage_snapshot').nullable()],
    ['total_tokens', (table) => table.integer('total_tokens').nullable()],
    ['manual_prompt_completed_at', (table) => table.timestamp('manual_prompt_completed_at', { useTz: true }).nullable()],
    ['manual_verification_completed_at', (table) =>
      table.timestamp('manual_verification_completed_at', { useTz: true }).nullable()],
    ['answered_at', (table) => table.timestamp('answered_at', { useTz: true }).nullable()],
    ['terminal_at', (table) => table.timestamp('terminal_at', { useTz: true }).nullable()],
    ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
    ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
  ];

  for (const [columnName, createColumn] of requiredRequestColumns) {
    const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_REQUEST_TABLE, columnName);

    if (!hasColumn) {
      await db.schema.alterTable(CHAT_CONVERSATION_REQUEST_TABLE, (table) => {
        createColumn(table);
      });
    }
  }

  const hasActivityTable = await db.schema.hasTable(CHAT_CONVERSATION_ACTIVITY_TABLE);

  if (!hasActivityTable) {
    await db.schema.createTable(CHAT_CONVERSATION_ACTIVITY_TABLE, (table) => {
      table.increments('id').primary();
      table.string('session_id', 120).notNullable().index();
      table.string('request_id', 120).notNullable().index();
      table.integer('line_no').notNullable();
      table.text('text').notNullable();
      table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
      table.unique(['session_id', 'request_id', 'line_no']);
    });
  }

  const requiredActivityColumns: Array<[string, (table: any) => void]> = [
    ['session_id', (table) => table.string('session_id', 120).notNullable().index()],
    ['request_id', (table) => table.string('request_id', 120).notNullable().index()],
    ['line_no', (table) => table.integer('line_no').notNullable()],
    ['text', (table) => table.text('text').notNullable().defaultTo('')],
    ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
  ];

  for (const [columnName, createColumn] of requiredActivityColumns) {
    const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_ACTIVITY_TABLE, columnName);

    if (!hasColumn) {
      await db.schema.alterTable(CHAT_CONVERSATION_ACTIVITY_TABLE, (table) => {
        createColumn(table);
      });
    }
  }

  const hasSourceChangeTable = await db.schema.hasTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE);

  if (!hasSourceChangeTable) {
    await db.schema.createTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, (table) => {
      table.increments('id').primary();
      table.string('session_id', 120).notNullable().index();
      table.string('client_id', 120).nullable().index();
      table.string('request_id', 120).notNullable();
      table.string('conversation_title', 200).notNullable().defaultTo('새 대화');
      table.string('chat_type_id', 120).nullable();
      table.string('chat_type_label', 200).nullable();
      table.string('request_title', 200).notNullable().defaultTo('');
      table.text('question_text').notNullable().defaultTo('');
      table.text('answer_text').notNullable().defaultTo('');
      table.string('status', 40).notNullable().defaultTo('completed');
      table.timestamp('answered_at', { useTz: true }).nullable();
      table.timestamp('source_changed_at', { useTz: true }).nullable();
      table.text('feature_tags_json').notNullable().defaultTo('[]');
      table.text('changed_files_json').notNullable().defaultTo('[]');
      table.text('current_source_files_json').notNullable().defaultTo('[]');
      table.text('diff_blocks_json').notNullable().defaultTo('[]');
      table.boolean('has_source_changes').notNullable().defaultTo(false);
      table.string('review_status', 40).notNullable().defaultTo('not-reviewed');
      table.string('source_change_kind', 40).notNullable().defaultTo('request');
      table.text('source_entry_ids_json').notNullable().defaultTo('[]');
      table.timestamp('conversation_deleted_at', { useTz: true }).nullable();
      table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
      table.unique(['session_id', 'request_id']);
    });
  }

  const requiredSourceChangeColumns: Array<[string, (table: any) => void]> = [
    ['session_id', (table) => table.string('session_id', 120).notNullable().index()],
    ['client_id', (table) => table.string('client_id', 120).nullable().index()],
    ['request_id', (table) => table.string('request_id', 120).notNullable()],
    ['conversation_title', (table) => table.string('conversation_title', 200).notNullable().defaultTo('새 대화')],
    ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()],
    ['chat_type_label', (table) => table.string('chat_type_label', 200).nullable()],
    ['request_title', (table) => table.string('request_title', 200).notNullable().defaultTo('')],
    ['question_text', (table) => table.text('question_text').notNullable().defaultTo('')],
    ['answer_text', (table) => table.text('answer_text').notNullable().defaultTo('')],
    ['status', (table) => table.string('status', 40).notNullable().defaultTo('completed')],
    ['answered_at', (table) => table.timestamp('answered_at', { useTz: true }).nullable()],
    ['source_changed_at', (table) => table.timestamp('source_changed_at', { useTz: true }).nullable()],
    ['feature_tags_json', (table) => table.text('feature_tags_json').notNullable().defaultTo('[]')],
    ['changed_files_json', (table) => table.text('changed_files_json').notNullable().defaultTo('[]')],
    ['current_source_files_json', (table) => table.text('current_source_files_json').notNullable().defaultTo('[]')],
    ['diff_blocks_json', (table) => table.text('diff_blocks_json').notNullable().defaultTo('[]')],
    ['has_source_changes', (table) => table.boolean('has_source_changes').notNullable().defaultTo(false)],
    ['review_status', (table) => table.string('review_status', 40).notNullable().defaultTo('not-reviewed')],
    ['source_change_kind', (table) => table.string('source_change_kind', 40).notNullable().defaultTo('request')],
    ['source_entry_ids_json', (table) => table.text('source_entry_ids_json').notNullable().defaultTo('[]')],
    ['conversation_deleted_at', (table) => table.timestamp('conversation_deleted_at', { useTz: true }).nullable()],
    ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
  ];

  for (const [columnName, createColumn] of requiredSourceChangeColumns) {
    const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, columnName);

    if (!hasColumn) {
      await db.schema.alterTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, (table) => {
        createColumn(table);
      });
    }
  }
}

export async function getChatConversation(sessionId: string, clientId?: string | null) {
  const normalizedSessionId = sessionId.trim();
  let row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first();

  if (!row) {
    return null;
  }

  const currentRequestId = String(row.current_request_id ?? '').trim() || null;

  if (
    shouldClearConversationJobState({
      currentRequestId,
      currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'],
      currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
      runtimeActive: isRuntimeRequestActive(currentRequestId),
      request: currentRequestId
        ? await db(CHAT_CONVERSATION_REQUEST_TABLE)
            .where({
              session_id: normalizedSessionId,
              request_id: currentRequestId,
            })
            .first()
            .then((requestRow) =>
              requestRow
                ? {
                    requestId: String(requestRow.request_id ?? ''),
                    status: String(requestRow.status ?? '') as ChatConversationRequestStatus,
                    responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id),
                    responseText: String(requestRow.response_text ?? ''),
                    terminalAt: normalizeDateTimeValue(requestRow.terminal_at),
                    updatedAt: normalizeDateTimeValue(requestRow.updated_at),
                  }
                : null,
            )
        : null,
    })
  ) {
    const shouldMarkRequestFailed =
      currentRequestId &&
      !isRuntimeRequestActive(currentRequestId) &&
      ['queued', 'started'].includes(String(row.current_job_status ?? '').trim());

    if (shouldMarkRequestFailed) {
      await db(CHAT_CONVERSATION_REQUEST_TABLE)
        .where({
          session_id: normalizedSessionId,
          request_id: currentRequestId,
        })
        .whereIn('status', ['queued', 'started'])
        .update({
          status: 'failed',
          status_message: '중단된 오래된 요청',
          terminal_at: db.fn.now(),
          updated_at: db.fn.now(),
        });
    }

    await db(CHAT_CONVERSATION_TABLE)
      .where({ session_id: normalizedSessionId })
      .update({
        current_request_id: null,
        current_job_status: null,
        current_job_message: null,
        current_queue_size: 0,
        current_status_updated_at: db.fn.now(),
        updated_at: db.fn.now(),
      });
    row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first();

    if (!row) {
      return null;
    }
  }

  const mapped = mapConversationRow(row);
  const latestPreviewMessageMap = await getLatestPreviewableMessageMap([normalizedSessionId]);
  const latestRequestPreviewMap = await getLatestRequestPreviewMap([normalizedSessionId]);
  const previewResolvedConversation = resolveConversationPreviewOverride(
    mapped,
    latestPreviewMessageMap.get(normalizedSessionId),
    latestRequestPreviewMap.get(normalizedSessionId),
  );
  const normalizedClientId = normalizeClientId(clientId);

  if (!normalizedClientId) {
    return previewResolvedConversation;
  }

  const preference = await getChatConversationClientPreference(sessionId, normalizedClientId);
  const latestResponseMessageId = await getLatestResponseMessageId(normalizedSessionId);
  return {
    ...previewResolvedConversation,
    clientId: normalizedClientId,
    notifyOffline: preference?.notifyOffline ?? previewResolvedConversation.notifyOffline,
    hasUnreadResponse:
      latestResponseMessageId != null &&
      latestResponseMessageId > (preference?.lastReadResponseMessageId ?? 0),
  };
}

export async function createChatConversation(payload: z.input<typeof conversationPayloadSchema>) {
  const parsed = conversationPayloadSchema.parse(payload);
  const normalizedClientId = normalizeClientId(parsed.clientId);
  const notifyOffline = parsed.notifyOffline ?? true;
  await db(CHAT_CONVERSATION_TABLE)
    .insert({
      session_id: parsed.sessionId,
      client_id: normalizedClientId,
      title: parsed.title?.trim() || '새 대화',
      draft_text: parsed.draftText ?? '',
      request_badge_label: parsed.requestBadgeLabel?.trim() || null,
      codex_model: parsed.codexModel?.trim() || null,
      chat_type_id: parsed.chatTypeId?.trim() || null,
      last_chat_type_id: parsed.lastChatTypeId?.trim() || parsed.chatTypeId?.trim() || null,
      general_section_name: parsed.generalSectionName?.trim() || null,
      context_label: parsed.contextLabel?.trim() || null,
      context_description: parsed.contextDescription?.trim() || null,
      notify_offline: notifyOffline,
      current_request_id: null,
      current_job_status: null,
      current_job_message: null,
      current_queue_size: 0,
      current_status_updated_at: null,
      last_message_preview: '',
      last_message_at: null,
      created_at: db.fn.now(),
      updated_at: db.fn.now(),
    })
    .onConflict('session_id')
    .ignore();

  if (normalizedClientId) {
    const existingPreference = await getChatConversationClientPreference(parsed.sessionId, normalizedClientId);

    if (!existingPreference) {
      await upsertChatConversationClientPreference(parsed.sessionId, normalizedClientId, notifyOffline);
    }
  }

  const conversation = await getChatConversation(parsed.sessionId, normalizedClientId);

  if (!conversation) {
    throw new Error('채팅방을 저장했지만 다시 불러오지 못했습니다.');
  }

  return conversation;
}

export async function updateChatConversationContext(
  sessionId: string,
  payload: {
    title?: string | null;
    draftText?: string | null;
    requestBadgeLabel?: string | null;
    clientId?: string | null;
    codexModel?: string | null;
    chatTypeId?: string | null;
    lastChatTypeId?: string | null;
    generalSectionName?: string | null;
    contextLabel?: string | null;
    contextDescription?: string | null;
    notifyOffline?: boolean | null;
  },
) {
  const normalizedClientId = normalizeClientId(payload.clientId);
  const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first();

  if (!current) {
    return null;
  }

  const nextUpdatePayload: Record<string, unknown> = {
    ...buildChatConversationContextUpdateFields({
      current,
      payload,
    }),
    updated_at: db.fn.now(),
  };

  await db(CHAT_CONVERSATION_TABLE)
    .where({ session_id: sessionId.trim() })
    .update(nextUpdatePayload);

  if (normalizedClientId && payload.notifyOffline != null) {
    await upsertChatConversationClientPreference(sessionId, normalizedClientId, payload.notifyOffline);
  }

  return getChatConversation(sessionId, normalizedClientId);
}

export function resolveNextConversationChatTypeId(currentChatTypeId?: string | null, requestedChatTypeId?: string | null) {
  const normalizedCurrentChatTypeId = String(currentChatTypeId ?? '').trim() || null;
  const normalizedRequestedChatTypeId = String(requestedChatTypeId ?? '').trim() || null;
  return normalizedRequestedChatTypeId ?? normalizedCurrentChatTypeId ?? null;
}

export function buildChatConversationContextUpdateFields(args: {
  current: Record<string, unknown>;
  payload: {
    title?: string | null;
    draftText?: string | null;
    requestBadgeLabel?: string | null;
    clientId?: string | null;
    codexModel?: string | null;
    chatTypeId?: string | null;
    lastChatTypeId?: string | null;
    generalSectionName?: string | null;
    contextLabel?: string | null;
    contextDescription?: string | null;
    notifyOffline?: boolean | null;
  };
}) {
  const hasDefinedPayloadValue = (key: keyof typeof args.payload) =>
    Object.prototype.hasOwnProperty.call(args.payload, key) && args.payload[key] !== undefined;
  const currentTitle = String(args.current.title ?? '').trim() || null;
  const currentDraftText = typeof args.current.draft_text === 'string' ? String(args.current.draft_text) : '';
  const currentRequestBadgeLabel = String(args.current.request_badge_label ?? '').trim() || null;
  const currentCodexModel = String(args.current.codex_model ?? '').trim() || null;
  const currentGeneralSectionName = String(args.current.general_section_name ?? '').trim() || null;
  const currentContextLabel = String(args.current.context_label ?? '').trim() || null;
  const currentContextDescription = String(args.current.context_description ?? '').trim() || null;
  const normalizedClientId = normalizeClientId(args.payload.clientId);
  const currentChatTypeId = String(args.current.chat_type_id ?? '').trim() || null;
  const requestedChatTypeId = args.payload.chatTypeId?.trim() || null;
  const nextChatTypeId = resolveNextConversationChatTypeId(currentChatTypeId, requestedChatTypeId);
  const requestedTitle = args.payload.title?.trim() || null;
  const requestedContextLabel = args.payload.contextLabel?.trim() || null;
  const requestedContextDescription = args.payload.contextDescription?.trim() || null;
  const hasRequestedCodexModel = hasDefinedPayloadValue('codexModel');
  const hasRequestedChatTypeId = hasDefinedPayloadValue('chatTypeId');
  const hasRequestedLastChatTypeId = hasDefinedPayloadValue('lastChatTypeId');
  const hasRequestedTitle = hasDefinedPayloadValue('title');
  const hasRequestedDraftText = hasDefinedPayloadValue('draftText');
  const hasRequestedRequestBadgeLabel = hasDefinedPayloadValue('requestBadgeLabel');
  const hasRequestedGeneralSectionName = hasDefinedPayloadValue('generalSectionName');
  const hasRequestedContextLabel = hasDefinedPayloadValue('contextLabel');
  const hasRequestedContextDescription = hasDefinedPayloadValue('contextDescription');
  const hasRequestedNotifyOffline = hasDefinedPayloadValue('notifyOffline');
  const nextUpdatePayload: Record<string, unknown> = {};

  if (hasRequestedTitle) {
    nextUpdatePayload.title =
      resolveNextConversationContextValue(currentTitle, requestedTitle, hasRequestedTitle) ?? '새 대화';
  }

  if (hasRequestedDraftText) {
    nextUpdatePayload.draft_text = args.payload.draftText ?? currentDraftText;
  }

  if (hasRequestedRequestBadgeLabel) {
    nextUpdatePayload.request_badge_label = resolveNextConversationContextValue(
      currentRequestBadgeLabel,
      args.payload.requestBadgeLabel,
      hasRequestedRequestBadgeLabel,
    );
  }

  if (hasRequestedCodexModel) {
    nextUpdatePayload.codex_model = resolveNextConversationContextValue(
      currentCodexModel,
      args.payload.codexModel,
      hasRequestedCodexModel,
    );
  }

  if (normalizedClientId && normalizedClientId !== String(args.current.client_id ?? '').trim()) {
    nextUpdatePayload.client_id = normalizedClientId;
  }

  if (hasRequestedChatTypeId) {
    nextUpdatePayload.chat_type_id = nextChatTypeId;
  }

  if (hasRequestedChatTypeId || hasRequestedLastChatTypeId) {
    nextUpdatePayload.last_chat_type_id = hasRequestedLastChatTypeId
      ? args.payload.lastChatTypeId?.trim() || nextChatTypeId || args.current.last_chat_type_id || null
      : nextChatTypeId;
  }

  if (hasRequestedGeneralSectionName) {
    nextUpdatePayload.general_section_name = resolveNextConversationContextValue(
      currentGeneralSectionName,
      args.payload.generalSectionName,
      hasRequestedGeneralSectionName,
    );
  }

  if (hasRequestedContextLabel) {
    nextUpdatePayload.context_label = resolveNextConversationContextValue(
      currentContextLabel,
      requestedContextLabel,
      hasRequestedContextLabel,
    );
  }

  if (hasRequestedContextDescription) {
    nextUpdatePayload.context_description = resolveNextConversationContextValue(
      currentContextDescription,
      requestedContextDescription,
      hasRequestedContextDescription,
    );
  }

  if (normalizedClientId == null && hasRequestedNotifyOffline && args.payload.notifyOffline != null) {
    nextUpdatePayload.notify_offline = args.payload.notifyOffline;
  }

  return nextUpdatePayload;
}

export function resolveNextConversationContextValue(
  currentValue: string | null | undefined,
  requestedValue: string | null | undefined,
  hasRequestedValue: boolean,
) {
  if (!hasRequestedValue) {
    return String(currentValue ?? '').trim() || null;
  }

  return String(requestedValue ?? '').trim() || null;
}

export async function listChatConversations(
  clientId?: string | null,
  limit = 50,
  unreadStateClientId?: string | null,
) {
  const normalizedClientId = normalizeClientId(clientId);
  const normalizedUnreadStateClientId = normalizeClientId(unreadStateClientId ?? clientId);
  const normalizedLimit = Math.max(1, Math.min(200, Math.round(limit)));
  let conversationListScopeClientId = normalizedClientId;
  const buildConversationListQuery = (targetClientId?: string | null) => {
    const query = db(CHAT_CONVERSATION_TABLE)
      .select('*')
      .orderByRaw('COALESCE(last_message_at, updated_at, created_at) DESC NULLS LAST')
      .orderByRaw('last_message_at DESC NULLS LAST')
      .orderByRaw('updated_at DESC NULLS LAST')
      .orderByRaw('created_at DESC NULLS LAST')
      .limit(normalizedLimit);

    if (targetClientId) {
      query.where((builder) => {
        builder
          .where({ client_id: targetClientId })
          .orWhereExists(
            db(CHAT_CONVERSATION_CLIENT_TABLE)
              .select(db.raw('1'))
              .whereRaw(`${CHAT_CONVERSATION_CLIENT_TABLE}.session_id = ${CHAT_CONVERSATION_TABLE}.session_id`)
              .andWhere({ client_id: targetClientId }),
          );
      });
    }

    return query;
  };

  let rows = await buildConversationListQuery(normalizedClientId);

  // Browser storage reset can regenerate client_id and hide existing rooms.
  // When that happens, fall back to the recent global list so the user can recover.
  if (normalizedClientId && rows.length === 0) {
    conversationListScopeClientId = null;
    rows = await buildConversationListQuery(null);
  }

  const sessionIds = rows.map((row) => String(row.session_id ?? '')).filter(Boolean);
  const currentRequestIds = Array.from(
    new Set(rows.map((row) => String(row.current_request_id ?? '').trim()).filter(Boolean)),
  );

  if (sessionIds.length > 0 && currentRequestIds.length > 0) {
    const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE)
      .select('session_id', 'request_id', 'status', 'response_message_id', 'response_text', 'terminal_at')
      .whereIn('session_id', sessionIds)
      .whereIn('request_id', currentRequestIds);
    const requestMap = new Map(
      requestRows.map((requestRow) => [
        `${String(requestRow.session_id ?? '').trim()}:${String(requestRow.request_id ?? '').trim()}`,
        {
          requestId: String(requestRow.request_id ?? ''),
          status: String(requestRow.status ?? '') as ChatConversationRequestStatus,
          responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id),
          responseText: String(requestRow.response_text ?? ''),
          terminalAt: normalizeDateTimeValue(requestRow.terminal_at),
        },
      ]),
    );
    const staleSessionIds = rows
      .filter((row) =>
        shouldClearConversationJobState({
          currentRequestId: String(row.current_request_id ?? ''),
          currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'],
          currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
          runtimeActive: isRuntimeRequestActive(String(row.current_request_id ?? '')),
          request:
            requestMap.get(`${String(row.session_id ?? '').trim()}:${String(row.current_request_id ?? '').trim()}`) ?? null,
        }),
      )
      .map((row) => String(row.session_id ?? '').trim())
      .filter(Boolean);

    if (staleSessionIds.length > 0) {
      const staleRequestIds = rows
        .filter((row) => staleSessionIds.includes(String(row.session_id ?? '').trim()))
        .map((row) => ({
          sessionId: String(row.session_id ?? '').trim(),
          requestId: String(row.current_request_id ?? '').trim(),
          status: String(row.current_job_status ?? '').trim(),
        }))
        .filter((item) => item.requestId && (item.status === 'queued' || item.status === 'started'));

      for (const staleItem of staleRequestIds) {
        await db(CHAT_CONVERSATION_REQUEST_TABLE)
          .where({
            session_id: staleItem.sessionId,
            request_id: staleItem.requestId,
          })
          .whereIn('status', ['queued', 'started'])
          .update({
            status: 'failed',
            status_message: '중단된 오래된 요청',
            terminal_at: db.fn.now(),
            updated_at: db.fn.now(),
          });
      }

      await db(CHAT_CONVERSATION_TABLE)
        .whereIn('session_id', staleSessionIds)
        .update({
          current_request_id: null,
          current_job_status: null,
          current_job_message: null,
          current_queue_size: 0,
          current_status_updated_at: db.fn.now(),
          updated_at: db.fn.now(),
        });
      rows = await buildConversationListQuery(conversationListScopeClientId);
    }
  }

  rows = rows.filter((row) => !isManagedChatShareSessionId(String(row.session_id ?? '')));

  if (rows.length > 0) {
    const candidateSessionIds = rows.map((row) => String(row.session_id ?? '').trim()).filter(Boolean);
    const [messageSessionRows, requestSessionRows] = await Promise.all([
      db(CHAT_CONVERSATION_MESSAGE_TABLE).distinct('session_id').whereIn('session_id', candidateSessionIds),
      db(CHAT_CONVERSATION_REQUEST_TABLE).distinct('session_id').whereIn('session_id', candidateSessionIds),
    ]);
    const visibleSessionIds = new Set(
      [...messageSessionRows, ...requestSessionRows]
        .map((row) => String(row.session_id ?? '').trim())
        .filter(Boolean),
    );

    rows = rows.filter((row) => {
      const sessionId = String(row.session_id ?? '').trim();
      return visibleSessionIds.has(sessionId) || hasConversationMetadata(row);
    });
  }

  const latestPreviewMessageMap = await getLatestPreviewableMessageMap(
    rows.map((row) => String(row.session_id ?? '')),
  );
  const latestRequestPreviewMap = await getLatestRequestPreviewMap(
    rows.map((row) => String(row.session_id ?? '')),
  );
  const latestResponsePreviewMap = await getLatestResponsePreviewMap(
    rows.map((row) => String(row.session_id ?? '')),
  );
  const pendingAttentionBySessionId = await getConversationPendingAttentionMap(
    rows.map((row) => String(row.session_id ?? '')),
  );
  const latestResponseMessageIdMap = await getLatestResponseMessageIdMap(
    rows.map((row) => String(row.session_id ?? '')),
  );
  const latestCodexPromptPartsMap = await getLatestCodexPromptPartsMap(
    rows.map((row) => String(row.session_id ?? '')),
  );
  const mapListConversationRow = (row: Record<string, unknown>) => ({
    ...mapConversationRow(row),
    contextDescription: summarizeConversationListContextDescription(row.context_description as string | null | undefined),
  });

  if (!normalizedUnreadStateClientId) {
    return rows
      .map((row) => {
        const mapped = mapListConversationRow(row);
        const pendingWorkState = resolvePendingWorkState({
          requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '',
          responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '',
          latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId),
        });
        return {
          ...resolveConversationPreviewOverride(
            mapped,
            latestPreviewMessageMap.get(mapped.sessionId),
            latestRequestPreviewMap.get(mapped.sessionId),
          ),
          ...pendingWorkState,
          lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
          lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
          hasUnreadResponse: false,
          hasPendingAttention: pendingAttentionBySessionId.get(mapped.sessionId) === true,
        };
      })
      .sort((left, right) =>
        (right.lastMessageAt ?? right.updatedAt).localeCompare(left.lastMessageAt ?? left.updatedAt),
      );
  }

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

  const preferences = await db(CHAT_CONVERSATION_CLIENT_TABLE)
    .select('*')
    .where({ client_id: normalizedUnreadStateClientId })
    .whereIn(
      'session_id',
      rows.map((row) => String(row.session_id ?? '')).filter(Boolean),
    );

  const preferenceMap = new Map(
    preferences.map((row) => {
      const mapped = mapClientPreferenceRow(row);
      return [mapped.sessionId, mapped];
    }),
  );

  return rows
    .map((row) => {
      const mapped = mapListConversationRow(row);
      const preference = preferenceMap.get(mapped.sessionId);
      const latestPreviewMessage = latestPreviewMessageMap.get(mapped.sessionId);
      const pendingWorkState = resolvePendingWorkState({
        requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '',
        responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '',
        latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId),
      });

      return {
        ...resolveConversationPreviewOverride(
          mapped,
          latestPreviewMessage,
          latestRequestPreviewMap.get(mapped.sessionId),
        ),
        ...pendingWorkState,
        lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
        lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
        clientId: normalizedUnreadStateClientId,
        notifyOffline: preference?.notifyOffline ?? mapped.notifyOffline,
        hasUnreadResponse:
          (latestResponseMessageIdMap.get(mapped.sessionId) ?? 0) >
          (preference?.lastReadResponseMessageId ?? 0),
        hasPendingAttention: pendingAttentionBySessionId.get(mapped.sessionId) === true,
      };
    })
    .sort((left, right) =>
      (right.lastMessageAt ?? right.updatedAt).localeCompare(left.lastMessageAt ?? left.updatedAt),
    );
}

export async function listChatConversationMessages(
  sessionId: string,
  options: {
    limit?: number;
    beforeMessageId?: number | null;
  } = {},
) {
  const normalizedLimit = Math.max(1, Math.min(1000, Math.round(options.limit ?? 200)));
  const normalizedBeforeMessageId =
    Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0
      ? Math.trunc(options.beforeMessageId as number)
      : null;
  const query = db(CHAT_CONVERSATION_MESSAGE_TABLE)
    .select('*')
    .where({ session_id: sessionId.trim() })
    .modify((builder) => {
      if (normalizedBeforeMessageId !== null) {
        builder.where('message_id', '<', normalizedBeforeMessageId);
      }

      builder.andWhere((visibilityBuilder) => {
        applyVisibleConversationMessageCondition(visibilityBuilder);
      });
    })
    .orderBy('message_id', 'desc')
    .orderBy('id', 'desc')
    .limit(normalizedLimit);
  const latestRows = await query;

  return latestRows.reverse().map((row: Parameters<typeof mapMessageRow>[0]) => mapMessageRow(row));
}

export async function listChatConversationActivityLogsByRequestIds(
  sessionId: string,
  requestIds: string[],
): Promise<ChatConversationActivityLogItem[]> {
  const normalizedSessionId = sessionId.trim();
  const normalizedRequestIds = Array.from(new Set(requestIds.map((item) => item.trim()).filter(Boolean)));

  if (!normalizedSessionId || normalizedRequestIds.length === 0) {
    return [];
  }

  const rows = await db(CHAT_CONVERSATION_ACTIVITY_TABLE)
    .select('session_id', 'request_id', 'text', 'line_no', 'created_at')
    .where({ session_id: normalizedSessionId })
    .whereIn('request_id', normalizedRequestIds)
    .orderBy('request_id', 'asc')
    .orderBy('line_no', 'asc')
    .orderBy('id', 'asc');

  const activityMap = new Map<string, ChatConversationActivityLogItem>();

  for (const row of rows) {
    const requestId = String(row.request_id ?? '').trim();

    if (!requestId) {
      continue;
    }

    const existing = activityMap.get(requestId);

    if (existing) {
      existing.lines.push(String(row.text ?? ''));
      existing.updatedAt = normalizeDateTimeValue(row.created_at) ?? existing.updatedAt;
      continue;
    }

    activityMap.set(requestId, {
      sessionId: String(row.session_id ?? normalizedSessionId),
      requestId,
      lines: [String(row.text ?? '')],
      updatedAt: normalizeDateTimeValue(row.created_at),
    });
  }

  return normalizedRequestIds
    .map((requestId) => activityMap.get(requestId))
    .filter(Boolean) as ChatConversationActivityLogItem[];
}

async function resolveConversationRequestCursor(sessionId: string, beforeMessageId: number) {
  const normalizedSessionId = sessionId.trim();
  const normalizedBeforeMessageId = Math.trunc(beforeMessageId);

  if (!normalizedSessionId || normalizedBeforeMessageId <= 0) {
    return null;
  }

  const directRequestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .select('request_id', 'created_at')
    .where({ session_id: normalizedSessionId })
    .andWhere((builder) => {
      builder.where('user_message_id', normalizedBeforeMessageId).orWhere('response_message_id', normalizedBeforeMessageId);
    })
    .first();

  if (directRequestRow) {
    return {
      requestId: String(directRequestRow.request_id ?? '').trim(),
      createdAt: normalizeDateTimeValue(directRequestRow.created_at) ?? '',
    };
  }

  const messageRow = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
    .select('client_request_id')
    .where({
      session_id: normalizedSessionId,
      message_id: normalizedBeforeMessageId,
    })
    .first();
  const linkedRequestId = String(messageRow?.client_request_id ?? '').trim();

  if (!linkedRequestId) {
    return null;
  }

  const linkedRequestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .select('request_id', 'created_at')
    .where({
      session_id: normalizedSessionId,
      request_id: linkedRequestId,
    })
    .first();

  if (!linkedRequestRow) {
    return null;
  }

  return {
    requestId: String(linkedRequestRow.request_id ?? '').trim(),
    createdAt: normalizeDateTimeValue(linkedRequestRow.created_at) ?? '',
  };
}

export async function listChatConversationDetailPage(
  sessionId: string,
  options: {
    limit?: number;
    beforeMessageId?: number | null;
    includeActivityLogs?: boolean;
    includePagination?: boolean;
  } = {},
): Promise<ChatConversationDetailPage> {
  const normalizedSessionId = sessionId.trim();
  await getChatConversation(normalizedSessionId, null);
  const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first();
  const normalizedLimit = Math.max(1, Math.min(100, Math.round(options.limit ?? 10)));
  const includeActivityLogs = options.includeActivityLogs !== false;
  const includePagination = options.includePagination !== false;
  const normalizedBeforeMessageId =
    Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0
      ? Math.trunc(options.beforeMessageId as number)
      : null;
  const requestCursor =
    normalizedBeforeMessageId == null
      ? null
      : await resolveConversationRequestCursor(normalizedSessionId, normalizedBeforeMessageId);

  const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({ session_id: normalizedSessionId })
    .whereNot('status', 'removed')
    .modify((builder) => {
      if (!requestCursor) {
        return;
      }

      builder.andWhere((cursorBuilder) => {
        cursorBuilder
          .where('created_at', '<', requestCursor.createdAt)
          .orWhere((sameTimeBuilder) => {
            sameTimeBuilder.where('created_at', '=', requestCursor.createdAt).andWhere('request_id', '<', requestCursor.requestId);
          });
      });
    })
    .orderBy('created_at', 'desc')
    .orderBy('request_id', 'desc')
    .limit(normalizedLimit);

  const orderedRequestRows = [...requestRows].reverse();
  const requests = await reconcileStaleConversationRequests(normalizedSessionId, orderedRequestRows, conversation);
  const requestIds = requests.map((item) => item.requestId.trim()).filter(Boolean);

  if (requestIds.length === 0) {
    return {
      messages: [],
      requests,
      activityLogs: [],
      oldestLoadedMessageId: null,
      hasOlderMessages: false,
    };
  }

  const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
    .select('*')
    .where({ session_id: normalizedSessionId })
    .whereIn('client_request_id', requestIds)
    .andWhere((builder) => {
      applyVisibleConversationMessageCondition(builder);
    })
    .orderBy('message_id', 'asc')
    .orderBy('id', 'asc');
  const messages = messageRows.map((row: Parameters<typeof mapMessageRow>[0]) => mapMessageRow(row));
  const activityLogs = includeActivityLogs
    ? await listChatConversationActivityLogsByRequestIds(normalizedSessionId, requestIds)
    : [];
  const oldestLoadedMessageId = includePagination
    ? (
      requests.reduce<number | null>((oldestId, request) => {
        const candidateIds = [request.userMessageId, request.responseMessageId].filter(
          (value): value is number => typeof value === 'number' && Number.isInteger(value) && value > 0,
        );

        if (candidateIds.length === 0) {
          return oldestId;
        }

        const nextCandidateId = Math.min(...candidateIds);
        return oldestId == null ? nextCandidateId : Math.min(oldestId, nextCandidateId);
      }, null) ?? messages[0]?.id ?? null
    )
    : null;
  const oldestRequest = includePagination ? requests[0] ?? null : null;
  const hasOlderMessages = oldestRequest
    ? Boolean(
        await db(CHAT_CONVERSATION_REQUEST_TABLE)
          .where({ session_id: normalizedSessionId })
          .whereNot('status', 'removed')
          .andWhere((builder) => {
            builder
              .where('created_at', '<', oldestRequest.createdAt)
              .orWhere((sameTimeBuilder) => {
                sameTimeBuilder.where('created_at', '=', oldestRequest.createdAt).andWhere('request_id', '<', oldestRequest.requestId);
              });
          })
          .first(),
      )
    : false;

  return {
    messages,
    requests,
    activityLogs,
    oldestLoadedMessageId,
    hasOlderMessages,
  };
}

export async function listChatConversationRequests(sessionId: string, limit = 200) {
  const normalizedSessionId = sessionId.trim();
  await getChatConversation(normalizedSessionId, null);
  const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first();

  const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({ session_id: normalizedSessionId })
    .orderBy('created_at', 'asc')
    .limit(Math.max(1, Math.min(1000, Math.round(limit))));

  return reconcileStaleConversationRequests(normalizedSessionId, rows, conversation);
}

export async function listChatSourceChangeSnapshots(clientId?: string | null, limit = 200) {
  await ensureChatConversationTables();
  await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE)
    .where({ has_source_changes: true })
    .andWhere('changed_files_json', '[]')
    .andWhere('current_source_files_json', '[]')
    .andWhere('diff_blocks_json', '[]')
    .update({
      has_source_changes: false,
      updated_at: db.fn.now(),
    });

  const normalizedClientId = normalizeClientId(clientId);
  const normalizedLimit = Math.max(1, Math.min(500, Math.round(limit)));

  const buildQuery = (targetClientId?: string | null) =>
    db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE)
      .select('*')
      .where({ has_source_changes: true })
      .modify((builder) => {
        if (targetClientId) {
          builder.where({ client_id: targetClientId });
        }
      })
      .orderByRaw('COALESCE(source_changed_at, answered_at, updated_at) DESC NULLS LAST')
      .orderBy('updated_at', 'desc')
      .limit(normalizedLimit);

  let rows = await buildQuery(normalizedClientId);

  if (normalizedClientId && rows.length === 0) {
    rows = await buildQuery(null);
  }

  return rows.map((row: Parameters<typeof mapSourceChangeSnapshotRow>[0]) => mapSourceChangeSnapshotRow(row));
}

async function applyVerifiedSourceChangeGrouping(args: {
  sessionId: string;
  clientId?: string | null;
  conversationTitle?: string | null;
  chatTypeId?: string | null;
  chatTypeLabel?: string | null;
  requestId: string;
  questionText: string;
  answerText: string;
  status: ChatConversationRequestStatus;
  answeredAt?: string | null;
  updatedAt?: string | null;
}) {
  const metadata = parseSourceChangeVerificationMetadata(args.questionText);

  if (!metadata) {
    return;
  }

  const selectedFeatureKeys = parseSelectedPromptValues(args.answerText);
  const entryRefs = metadata.features.flatMap((feature) => feature.entryRefs);

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

  const sourceRows = await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE)
    .select('*')
    .where((builder) => {
      entryRefs.forEach((entryRef, index) => {
        const method = index === 0 ? 'where' : 'orWhere';
        builder[method]((nestedBuilder: any) => {
          nestedBuilder.where({
            session_id: entryRef.sessionId,
            request_id: entryRef.requestId,
          });
        });
      });
    });

  const sourceRowMap = new Map<string, Record<string, unknown>>();
  sourceRows.forEach((row) => {
    const sessionId = String(row.session_id ?? '').trim();
    const requestId = String(row.request_id ?? '').trim();

    if (sessionId && requestId) {
      sourceRowMap.set(`${sessionId}:${requestId}`, row);
    }
  });

  const activeSourceIds = Array.from(new Set(entryRefs.map((entryRef) => `${entryRef.sessionId}:${entryRef.requestId}`)));

  await db.transaction(async (trx) => {
    if (activeSourceIds.length > 0) {
      await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE)
        .where((builder) => {
          activeSourceIds.forEach((entryId, index) => {
            const dividerIndex = entryId.indexOf(':');
            const sessionId = dividerIndex >= 0 ? entryId.slice(0, dividerIndex) : '';
            const requestId = dividerIndex >= 0 ? entryId.slice(dividerIndex + 1) : '';
            const method = index === 0 ? 'where' : 'orWhere';

            builder[method]((nestedBuilder: any) => {
              nestedBuilder.where({
                session_id: sessionId,
                request_id: requestId,
              });
            });
          });
        })
        .update({
          has_source_changes: false,
          updated_at: db.fn.now(),
        });
    }

    await Promise.all(metadata.features.map(async (feature, index) => {
      const featureRows = feature.entryRefs
        .map((entryRef) => sourceRowMap.get(`${entryRef.sessionId}:${entryRef.requestId}`) ?? null)
        .filter((row): row is Record<string, unknown> => row != null);

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

      const changedFiles = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.changed_files_json)));
      const currentSourceFiles = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.current_source_files_json)));
      const diffBlocks = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.diff_blocks_json)));
      const payload = buildSourceChangeSnapshotPayload({
        sessionId: args.sessionId,
        clientId: args.clientId ?? null,
        conversationTitle: args.conversationTitle ?? null,
        chatTypeId: args.chatTypeId ?? null,
        chatTypeLabel: args.chatTypeLabel ?? null,
        requestId: buildVerificationGroupRequestId(args.requestId, feature.key, index),
        status: args.status,
        questionText: args.questionText,
        answerText: args.answerText,
        answeredAt: args.answeredAt ?? null,
        updatedAt: args.updatedAt ?? null,
        reviewStatus: selectedFeatureKeys.has(feature.key) ? 'reviewed' : 'not-reviewed',
        sourceChangeKind: 'verification-group',
        sourceEntryIds: feature.entryRefs.map((entryRef) => `${entryRef.sessionId}:${entryRef.requestId}`),
      });

      await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE)
        .insert({
          ...payload,
          request_title: `${feature.label} 검증`,
          feature_tags_json: stringifyStringArray([feature.label]),
          changed_files_json: changedFiles,
          current_source_files_json: currentSourceFiles,
          diff_blocks_json: diffBlocks,
          has_source_changes: hasMeaningfulChatSourceArtifacts({
            changedFiles: parseStringArray(changedFiles),
            currentSourceFiles: parseStringArray(currentSourceFiles),
            diffBlocks: parseStringArray(diffBlocks),
          }),
        })
        .onConflict(['session_id', 'request_id'])
        .merge();
    }));
  });
}

export async function syncChatSourceChangeSnapshot(sessionId: string, requestId: string) {
  await ensureChatConversationTables();

  const normalizedSessionId = sessionId.trim();
  const normalizedRequestId = requestId.trim();

  await getChatConversation(normalizedSessionId, null);

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

  const [conversation, request] = await Promise.all([
    db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(),
    db(CHAT_CONVERSATION_REQUEST_TABLE)
      .where({
        session_id: normalizedSessionId,
        request_id: normalizedRequestId,
      })
      .first(),
  ]);

  if (!request) {
    return null;
  }

  const status = String(request.status ?? 'accepted') as ChatConversationRequestStatus;
  const questionText = String(request.user_text ?? '').trim();
  const answerText = String(request.response_text ?? '').trim();

  if (status !== 'completed' || !answerText || isPreparingChatReplyText(answerText)) {
    return null;
  }

  const payload = buildSourceChangeSnapshotPayload({
    sessionId: normalizedSessionId,
    clientId: conversation?.client_id == null ? null : String(conversation.client_id),
    conversationTitle: String(conversation?.title ?? '새 대화'),
    chatTypeId: conversation?.chat_type_id == null ? null : String(conversation.chat_type_id),
    chatTypeLabel: conversation?.context_label == null ? null : String(conversation.context_label),
    requestId: normalizedRequestId,
    status,
    questionText,
    answerText,
    answeredAt: normalizeDateTimeValue(request.answered_at),
    updatedAt: normalizeDateTimeValue(request.updated_at),
  });

  await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE)
    .insert(payload)
    .onConflict(['session_id', 'request_id'])
    .merge(payload);

  await applyVerifiedSourceChangeGrouping({
    sessionId: normalizedSessionId,
    clientId: conversation?.client_id == null ? null : String(conversation.client_id),
    conversationTitle: String(conversation?.title ?? '새 대화'),
    chatTypeId: conversation?.chat_type_id == null ? null : String(conversation.chat_type_id),
    chatTypeLabel: conversation?.context_label == null ? null : String(conversation.context_label),
    requestId: normalizedRequestId,
    questionText,
    answerText,
    status,
    answeredAt: normalizeDateTimeValue(request.answered_at),
    updatedAt: normalizeDateTimeValue(request.updated_at),
  });

  const row = await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE)
    .where({
      session_id: normalizedSessionId,
      request_id: normalizedRequestId,
    })
    .first();

  return row ? mapSourceChangeSnapshotRow(row) : null;
}

export async function getChatConversationRequest(sessionId: string, requestId: string) {
  const normalizedSessionId = sessionId.trim();
  const normalizedRequestId = requestId.trim();

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

  await getChatConversation(normalizedSessionId, null);

  const conversation = await db(CHAT_CONVERSATION_TABLE)
    .where({ session_id: normalizedSessionId })
    .first();
  const row = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({
      session_id: normalizedSessionId,
      request_id: normalizedRequestId,
    })
    .first();

  if (!row) {
    return null;
  }

  const [item] = await reconcileStaleConversationRequests(normalizedSessionId, [row], conversation);
  return item ?? null;
}

async function refreshConversationPreview(sessionId: string) {
  const latestMessage = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
    .where({ session_id: sessionId.trim() })
    .whereIn('author', ['user', 'codex'])
    .orderBy('created_at', 'desc')
    .orderBy('id', 'desc')
    .first();

  await db(CHAT_CONVERSATION_TABLE)
    .where({ session_id: sessionId.trim() })
    .update({
      last_message_preview: latestMessage && isPreviewableConversationMessage(latestMessage) ? createPreview(String(latestMessage.text ?? '')) : '',
      last_message_at: latestMessage && isPreviewableConversationMessage(latestMessage) ? latestMessage.created_at : null,
      updated_at: db.fn.now(),
    });
}

export async function appendChatConversationMessage(
  conversationPayload: z.input<typeof conversationPayloadSchema>,
  messagePayload: z.input<typeof conversationMessagePayloadSchema>,
) {
  const conversation = conversationPayloadSchema.parse(conversationPayload);
  const message = conversationMessagePayloadSchema.parse(messagePayload);

  await createChatConversation(conversation);

  const currentConversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: conversation.sessionId }).first();
  const resolvedClientRequestId =
    message.clientRequestId?.trim() ||
    (message.author === 'codex' ? String(currentConversation?.current_request_id ?? '').trim() || null : null);

  await db(CHAT_CONVERSATION_MESSAGE_TABLE)
    .insert({
      session_id: message.sessionId,
      message_id: message.messageId,
      author: message.author,
      text: message.text,
      parts_json: stringifyChatMessageParts(message.parts),
      display_timestamp: message.timestamp,
      client_request_id: resolvedClientRequestId,
      created_at: db.fn.now(),
    })
    .onConflict(['session_id', 'message_id'])
    .merge({
      author: message.author,
      text: message.text,
      parts_json: stringifyChatMessageParts(message.parts),
      display_timestamp: message.timestamp,
      client_request_id: resolvedClientRequestId,
    });

  await db(CHAT_CONVERSATION_TABLE)
    .where({ session_id: conversation.sessionId })
    .update({
      client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null,
      draft_text: '',
      chat_type_id: currentConversation?.chat_type_id || conversation.chatTypeId?.trim() || null,
      last_chat_type_id:
        currentConversation?.chat_type_id ||
        currentConversation?.last_chat_type_id ||
        conversation.chatTypeId?.trim() ||
        conversation.lastChatTypeId?.trim() ||
        null,
      context_label:
        currentConversation?.chat_type_id || currentConversation?.context_label
          ? currentConversation?.context_label || null
          : conversation.contextLabel?.trim() || null,
      context_description:
        currentConversation?.chat_type_id || currentConversation?.context_description
          ? currentConversation?.context_description || null
          : conversation.contextDescription?.trim() || null,
      notify_offline:
        conversation.notifyOffline == null ? Boolean(currentConversation?.notify_offline) : conversation.notifyOffline,
      updated_at: db.fn.now(),
    });

  await refreshConversationPreview(conversation.sessionId);

  if (normalizeClientId(conversation.clientId) && conversation.notifyOffline != null) {
    await upsertChatConversationClientPreference(
      conversation.sessionId,
      normalizeClientId(conversation.clientId)!,
      conversation.notifyOffline,
    );
  }

  const requestPatch = buildChatConversationRequestPatchFromMessage({
    id: message.messageId,
    author: message.author,
    text: message.text,
    clientRequestId: resolvedClientRequestId,
  });

  if (requestPatch) {
    await upsertChatConversationRequest(conversation.sessionId, {
      requestId: requestPatch.requestId,
      requesterClientId:
        message.author === 'user'
          ? normalizeClientId(conversation.clientId)
          : undefined,
      status: requestPatch.status,
      userMessageId: requestPatch.userMessageId,
      userText: requestPatch.userText,
      responseMessageId: requestPatch.responseMessageId,
      responseText: requestPatch.responseText,
    });
  }
}

export async function appendChatConversationActivityLine(
  sessionId: string,
  requestId: string,
  line: string,
  lineNo?: number,
) {
  const normalizedSessionId = sessionId.trim();
  const normalizedRequestId = requestId.trim();
  const normalizedLine = line.trim();

  if (!normalizedSessionId || !normalizedRequestId || !normalizedLine) {
    return null;
  }

  const normalizedLineNo =
    Number.isInteger(lineNo) && Number(lineNo) > 0 ? Number(lineNo) : undefined;
  let nextLineNo = normalizedLineNo ?? null;

  if (nextLineNo == null) {
    const existingLineCountRow = await db(CHAT_CONVERSATION_ACTIVITY_TABLE)
      .where({
        session_id: normalizedSessionId,
        request_id: normalizedRequestId,
      })
      .count<{ count?: string | number }>('id as count')
      .first();
    nextLineNo = Number(existingLineCountRow?.count ?? 0) + 1;
  }

  await db(CHAT_CONVERSATION_ACTIVITY_TABLE)
    .insert({
      session_id: normalizedSessionId,
      request_id: normalizedRequestId,
      line_no: nextLineNo,
      text: normalizedLine,
      created_at: db.fn.now(),
    })
    .onConflict(['session_id', 'request_id', 'line_no'])
    .merge({
      text: normalizedLine,
      created_at: db.fn.now(),
    });

  return nextLineNo;
}

export async function listChatConversationActivityLogs(
  sessionId: string,
  limitRequests = 500,
): Promise<ChatConversationActivityLogItem[]> {
  const normalizedSessionId = sessionId.trim();

  if (!normalizedSessionId) {
    return [];
  }

  const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .select('request_id')
    .where({ session_id: normalizedSessionId })
    .whereNot('status', 'removed')
    .orderBy('created_at', 'desc')
    .orderBy('request_id', 'desc')
    .limit(limitRequests);
  const requestIds = requestRows
    .map((row) => String(row.request_id ?? '').trim())
    .filter(Boolean);

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

  const rows = await db(CHAT_CONVERSATION_ACTIVITY_TABLE)
    .select('session_id', 'request_id', 'text', 'line_no', 'created_at')
    .where({ session_id: normalizedSessionId })
    .whereIn('request_id', requestIds)
    .orderBy('request_id', 'asc')
    .orderBy('line_no', 'asc')
    .orderBy('id', 'asc');

  const activityMap = new Map<string, ChatConversationActivityLogItem>();

  for (const row of rows) {
    const requestId = String(row.request_id ?? '').trim();

    if (!requestId) {
      continue;
    }

    const existing = activityMap.get(requestId);

    if (existing) {
      existing.lines.push(String(row.text ?? ''));
      existing.updatedAt = normalizeDateTimeValue(row.created_at) ?? existing.updatedAt;
      continue;
    }

    activityMap.set(requestId, {
      sessionId: String(row.session_id ?? normalizedSessionId),
      requestId,
      lines: [String(row.text ?? '')],
      updatedAt: normalizeDateTimeValue(row.created_at),
    });
  }

  return requestIds.map((requestId) => activityMap.get(requestId)).filter(Boolean) as ChatConversationActivityLogItem[];
}

export async function listRecoverableChatConversationRequests(): Promise<RecoverableChatConversationRequestItem[]> {
  await ensureChatConversationTables();

  const rows = await db(`${CHAT_CONVERSATION_REQUEST_TABLE} as request`)
    .join(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'request.session_id')
    .select(
      'request.session_id',
      'request.request_id',
      'request.request_origin',
      'request.shared_resource_token_id',
      'request.parent_request_id',
      'request.status',
      'request.user_text',
      'request.created_at',
      'request.updated_at',
      'conversation.client_id',
      'conversation.chat_type_id',
      'conversation.last_chat_type_id',
      'conversation.general_section_name',
      'conversation.context_label',
      'conversation.context_description',
      'conversation.current_request_id',
      'conversation.current_job_status',
      'conversation.current_status_updated_at',
    )
    .whereIn('request.status', ['accepted', 'queued', 'started'])
    .andWhere((builder) => {
      builder.whereNull('request.terminal_at');
    })
    .andWhere((builder) => {
      builder.whereNull('request.response_message_id').orWhere('request.response_message_id', 0);
    })
    .orderByRaw(
      "case when request.request_id = conversation.current_request_id then 0 else 1 end asc",
    )
    .orderBy('request.session_id', 'asc')
    .orderBy('request.created_at', 'asc')
    .orderBy('request.request_id', 'asc');

  return rows
    .map((row) => {
      const sessionId = String(row.session_id ?? '').trim();
      const requestId = String(row.request_id ?? '').trim();
      const userText = String(row.user_text ?? '').trim();
      const createdAt = normalizeDateTimeValue(row.created_at) ?? '';

      if (!sessionId || !requestId || !userText || !createdAt) {
        return null;
      }

      return {
        sessionId,
        clientId: row.client_id == null ? null : String(row.client_id),
        chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id),
        lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id),
        generalSectionName: row.general_section_name == null ? null : String(row.general_section_name),
        contextLabel: row.context_label == null ? null : String(row.context_label),
        contextDescription: row.context_description == null ? null : String(row.context_description),
        currentRequestId: row.current_request_id == null ? null : String(row.current_request_id),
        currentJobStatus:
          row.current_job_status == null ? null : (String(row.current_job_status) as ChatConversationItem['currentJobStatus']),
        requestId,
        requestOrigin:
          row.request_origin == null
            ? null
            : String(row.request_origin) === 'prompt' || String(row.request_origin) === 'composer'
              ? (String(row.request_origin) as 'composer' | 'prompt')
              : null,
        sharedResourceTokenId: row.shared_resource_token_id == null ? null : String(row.shared_resource_token_id),
        parentRequestId: row.parent_request_id == null ? null : String(row.parent_request_id),
        status: String(row.status ?? 'accepted') as ChatConversationRequestStatus,
        userText,
        createdAt,
        updatedAt: normalizeDateTimeValue(row.updated_at) ?? createdAt,
        currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
      } satisfies RecoverableChatConversationRequestItem;
    })
    .filter((item): item is RecoverableChatConversationRequestItem => {
      if (!item) {
        return false;
      }

      return !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,
        },
      }) && !shouldFailStaleInProgressRequest({
        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,
        },
      });
    });
}

export async function updateChatConversationJobState(
  sessionId: string,
  payload: {
    requestId?: string | null;
    status?: 'queued' | 'started' | 'completed' | 'failed' | null;
    message?: string | null;
    queueSize?: number | null;
    clear?: boolean;
  },
) {
  const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first();

  if (!current) {
    return null;
  }

  const shouldClear = payload.clear === true;

  await db(CHAT_CONVERSATION_TABLE)
    .where({ session_id: sessionId.trim() })
    .update({
      current_request_id: shouldClear ? null : payload.requestId?.trim() || current.current_request_id || null,
      current_job_status: shouldClear ? null : payload.status ?? current.current_job_status ?? null,
      current_job_message: shouldClear ? null : payload.message?.trim() || null,
      current_queue_size: shouldClear ? 0 : Math.max(0, Math.round(payload.queueSize ?? current.current_queue_size ?? 0)),
      current_status_updated_at: db.fn.now(),
      updated_at: db.fn.now(),
    });

  const row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first();
  return row ? mapConversationRow(row) : null;
}

async function clearConversationJobStateForRequest(sessionId: string, requestId: string) {
  await db(CHAT_CONVERSATION_TABLE)
    .where({
      session_id: sessionId,
      current_request_id: requestId,
    })
    .update({
      current_request_id: null,
      current_job_status: null,
      current_job_message: null,
      current_queue_size: 0,
      current_status_updated_at: db.fn.now(),
      updated_at: db.fn.now(),
    });
}

export async function upsertChatConversationRequest(
  sessionId: string,
  payload: {
    requestId: string;
    requesterClientId?: string | null;
    chatTypeId?: string | null;
    chatTypeLabel?: string | null;
    requestOrigin?: 'composer' | 'prompt' | null;
    sharedResourceTokenId?: string | null;
    parentRequestId?: string | null;
    promptContextRef?: ChatConversationRequestItem['promptContextRef'];
    status?: ChatConversationRequestStatus;
    statusMessage?: string | null;
    retryCount?: number | null;
    incrementRetryCount?: boolean;
    allowTerminalStatusReset?: boolean;
    userMessageId?: number | null;
    userText?: string | null;
    responseMessageId?: number | null;
    responseText?: string | null;
    usageSnapshot?: ChatConversationRequestUsageSnapshot | null;
    totalTokens?: number | null;
  },
) {
  const normalizedSessionId = sessionId.trim();
  const normalizedRequestId = payload.requestId.trim();
  const normalizedRequesterClientId = payload.requesterClientId?.trim() || null;
  const normalizedChatTypeId = payload.chatTypeId?.trim() || null;
  const normalizedChatTypeLabel = payload.chatTypeLabel?.trim() || null;
  const normalizedRequestOrigin =
    payload.requestOrigin === 'prompt' || payload.requestOrigin === 'composer' ? payload.requestOrigin : null;
  const normalizedSharedResourceTokenId = payload.sharedResourceTokenId?.trim() || null;
  const normalizedParentRequestId = payload.parentRequestId?.trim() || null;
  const normalizedPromptContextRef = normalizeStoredPromptContextRef(payload.promptContextRef);

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

  let nextRow:
    | {
        session_id: string;
        request_id: string;
        requester_client_id: string | null;
        chat_type_id: string | null;
        chat_type_label: string | null;
        request_origin: 'composer' | 'prompt' | null;
        shared_resource_token_id: string | null;
        parent_request_id: string | null;
        prompt_context_ref: string | null;
        status: ChatConversationRequestStatus;
        status_message: string | null;
        retry_count: number;
        user_message_id: number | null;
        user_text: string;
        response_message_id: number | null;
        response_text: string;
        usage_snapshot: string | null;
        total_tokens: number | null;
        manual_prompt_completed_at: unknown;
        manual_verification_completed_at: unknown;
        answered_at: unknown;
        terminal_at: unknown;
        created_at: unknown;
        updated_at: unknown;
      }
    | null = null;
  let nextStatus: ChatConversationRequestStatus = payload.status ?? 'accepted';

  for (let attempt = 0; attempt < 3; attempt += 1) {
    const current = await db(CHAT_CONVERSATION_REQUEST_TABLE)
      .where({
        session_id: normalizedSessionId,
        request_id: normalizedRequestId,
      })
      .first();

    nextStatus = mergeChatConversationRequestStatus(
      (current?.status as ChatConversationRequestStatus | undefined) ?? null,
      payload.status ?? null,
      {
        allowTerminalStatusReset: payload.allowTerminalStatusReset === true,
      },
    );
    const currentRetryCount =
      current?.retry_count == null || current.retry_count === ''
        ? 0
        : Number.isFinite(Number(current.retry_count))
          ? Math.max(0, Math.round(Number(current.retry_count)))
          : 0;
    const payloadRetryCount =
      payload.retryCount == null
        ? null
        : Number.isFinite(Number(payload.retryCount))
          ? Math.max(0, Math.round(Number(payload.retryCount)))
          : 0;
    const nextRetryCount =
      payload.incrementRetryCount === true
        ? currentRetryCount + 1
        : payloadRetryCount ?? currentRetryCount;
    const currentStatus = (current?.status as ChatConversationRequestStatus | undefined) ?? null;
    const defaultStatusMessage =
      payload.status && payload.status !== currentStatus
        ? getDefaultChatConversationRequestStatusMessage(nextStatus)
        : null;
    const terminalStatus = ['completed', 'failed', 'cancelled', 'removed'].includes(nextStatus)
      ? db.fn.now()
      : payload.allowTerminalStatusReset === true
        ? null
        : current?.terminal_at ?? null;
    const answeredAt =
      payload.responseMessageId != null || (payload.responseText?.trim() ?? '').length > 0
        ? current?.answered_at ?? db.fn.now()
        : current?.answered_at ?? null;
    const normalizedUsageSnapshot =
      payload.usageSnapshot === undefined
        ? current?.usage_snapshot ?? null
        : payload.usageSnapshot
          ? JSON.stringify(payload.usageSnapshot)
          : null;
    const normalizedTotalTokens =
      payload.totalTokens === undefined
        ? current?.total_tokens == null
          ? null
          : Math.max(0, Math.round(Number(current.total_tokens)))
        : payload.totalTokens == null
          ? null
          : Math.max(0, Math.round(Number(payload.totalTokens)));

    nextRow = {
      session_id: normalizedSessionId,
      request_id: normalizedRequestId,
      requester_client_id: normalizedRequesterClientId ?? current?.requester_client_id ?? null,
      chat_type_id: normalizedChatTypeId ?? current?.chat_type_id ?? null,
      chat_type_label: normalizedChatTypeLabel ?? current?.chat_type_label ?? null,
      request_origin: normalizedRequestOrigin ?? current?.request_origin ?? null,
      shared_resource_token_id: normalizedSharedResourceTokenId ?? current?.shared_resource_token_id ?? null,
      parent_request_id: normalizedParentRequestId ?? current?.parent_request_id ?? null,
      prompt_context_ref:
        normalizedPromptContextRef != null
          ? JSON.stringify(normalizedPromptContextRef)
          : current?.prompt_context_ref ?? null,
      status: nextStatus,
      status_message: payload.statusMessage?.trim() || defaultStatusMessage || current?.status_message || null,
      retry_count: nextRetryCount,
      user_message_id: payload.userMessageId ?? current?.user_message_id ?? null,
      user_text: payload.userText ?? current?.user_text ?? '',
      response_message_id: payload.responseMessageId ?? current?.response_message_id ?? null,
      response_text: payload.responseText ?? current?.response_text ?? '',
      usage_snapshot: normalizedUsageSnapshot,
      total_tokens: normalizedTotalTokens,
      manual_prompt_completed_at: current?.manual_prompt_completed_at ?? null,
      manual_verification_completed_at: current?.manual_verification_completed_at ?? null,
      answered_at: answeredAt,
      terminal_at: terminalStatus,
      created_at: current?.created_at ?? db.fn.now(),
      updated_at: db.fn.now(),
    };

    if (current) {
      await db(CHAT_CONVERSATION_REQUEST_TABLE)
        .where({
          session_id: normalizedSessionId,
          request_id: normalizedRequestId,
        })
        .update(nextRow);
      break;
    }

    const insertedRows = await db(CHAT_CONVERSATION_REQUEST_TABLE)
      .insert(nextRow)
      .onConflict(['session_id', 'request_id'])
      .ignore()
      .returning(['session_id']);

    if (insertedRows.length > 0) {
      break;
    }
  }

  if (!nextRow) {
    return null;
  }

  const row = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({
      session_id: normalizedSessionId,
      request_id: normalizedRequestId,
    })
    .first();

  if (
    shouldClearConversationJobState({
      currentRequestId: normalizedRequestId,
      currentJobStatus: 'started',
      request: {
        requestId: normalizedRequestId,
        status: nextStatus,
        responseMessageId: nextRow.response_message_id,
        responseText: nextRow.response_text,
        terminalAt: nextRow.terminal_at == null ? null : String(nextRow.terminal_at),
      },
    })
  ) {
    await clearConversationJobStateForRequest(normalizedSessionId, normalizedRequestId);
  }

  if (
    nextStatus === 'completed' &&
    (nextRow.response_message_id != null || String(nextRow.response_text ?? '').trim().length > 0)
  ) {
    await syncChatSourceChangeSnapshot(normalizedSessionId, normalizedRequestId);
  }

  return row ? mapRequestRow(row) : null;
}

export async function markChatConversationRequestManualCompletion(
  sessionId: string,
  requestId: string,
  completionType: 'prompt' | 'verification',
) {
  await ensureChatConversationTables();

  const normalizedSessionId = sessionId.trim();
  const normalizedRequestId = requestId.trim();

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

  const existingRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({
      session_id: normalizedSessionId,
      request_id: normalizedRequestId,
    })
    .first();

  if (!existingRow) {
    return null;
  }

  if (completionType === 'verification') {
    const childRequestCountRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
      .where({
        session_id: normalizedSessionId,
        parent_request_id: normalizedRequestId,
      })
      .count<{ count: string | number }[]>({ count: '*' })
      .first();

    const childRequestCount = Number(childRequestCountRow?.count ?? 0) || 0;

    if (childRequestCount > 0) {
      throw new ChatConversationManualCompletionBlockedError(
        '후속 요청이 있는 답변은 응답 확인 완료로 처리할 수 없습니다.',
      );
    }
  }

  const targetColumn =
    completionType === 'prompt' ? 'manual_prompt_completed_at' : 'manual_verification_completed_at';

  await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({
      session_id: normalizedSessionId,
      request_id: normalizedRequestId,
    })
    .update({
      [targetColumn]: db.fn.now(),
      updated_at: db.fn.now(),
    });

  const conversation = await db(CHAT_CONVERSATION_TABLE)
    .where({ session_id: normalizedSessionId })
    .first();
  const row = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({
      session_id: normalizedSessionId,
      request_id: normalizedRequestId,
    })
    .first();

  return row ? normalizeStaleRequestItem(mapRequestRow(row), conversation) : null;
}

export async function persistChatConversationPromptSelection(
  sessionId: string,
  parentRequestId: string,
  selection: ChatPromptSelectionPatch,
) {
  await ensureChatConversationTables();

  const normalizedSessionId = sessionId.trim();
  const normalizedParentRequestId = parentRequestId.trim();

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

  let requestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({
      session_id: normalizedSessionId,
      request_id: normalizedParentRequestId,
    })
    .first();

  const resolvedAt = new Date().toISOString();
  if (!requestRow) {
    return null;
  }

  const loadCandidateRequestRows = async () =>
    db(CHAT_CONVERSATION_REQUEST_TABLE)
      .where({
        session_id: normalizedSessionId,
      })
      .select('request_id', 'parent_request_id', 'response_message_id', 'created_at')
      .orderBy('created_at', 'desc')
      .orderBy('request_id', 'desc');

  let candidateRequestRows = await loadCandidateRequestRows();
  let candidateRequestIds = collectPromptSelectionCandidateRequestIds(candidateRequestRows, normalizedParentRequestId);
  const sourceMessageId = normalizePromptSelectionSourceMessageId(selection);

  if (candidateRequestIds.length === 0) {
    candidateRequestIds = [normalizedParentRequestId];
  }

  const candidateMessageRows: Record<string, unknown>[] = [];
  const seenMessageIds = new Set<string>();
  const appendCandidateMessageRows = (rows: Record<string, unknown>[]) => {
    rows.forEach((row) => {
      const messageId = String(row.message_id ?? row.id ?? '').trim();

      if (!messageId || seenMessageIds.has(messageId)) {
        return;
      }

      seenMessageIds.add(messageId);
      candidateMessageRows.push(row);
    });
  };
  const loadResponseMessageRow = async (targetRequestRow: Record<string, unknown> | undefined | null) => {
    const responseMessageId =
      targetRequestRow?.response_message_id == null ? null : Number(targetRequestRow.response_message_id);

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

    return db(CHAT_CONVERSATION_MESSAGE_TABLE)
      .where({
        session_id: normalizedSessionId,
        message_id: responseMessageId,
      })
      .first();
  };

  if (sourceMessageId != null) {
    const sourceMessageRow = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
      .where({
        session_id: normalizedSessionId,
        message_id: sourceMessageId,
      })
      .first();

    if (sourceMessageRow) {
      appendCandidateMessageRows([sourceMessageRow]);
    }
  }

  const appendCandidateResponseMessageRows = async (
    rows: Array<Record<string, unknown>>,
    preferredRequestIds: string[],
  ) => {
    const requestRowById = new Map(
      rows.map((row) => [String(row.request_id ?? '').trim(), row] as const).filter(([requestId]) => requestId),
    );

    for (const requestId of preferredRequestIds) {
      const targetRequestRow = requestRowById.get(requestId);

      if (!targetRequestRow) {
        continue;
      }

      const responseMessageRow = await loadResponseMessageRow(targetRequestRow);

      if (responseMessageRow) {
        appendCandidateMessageRows([responseMessageRow]);
      }
    }
  };

  await appendCandidateResponseMessageRows(candidateRequestRows, candidateRequestIds);

  if (candidateMessageRows.length === 0) {
    await repairChatConversationRequestLinks(normalizedSessionId);
    requestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
      .where({
        session_id: normalizedSessionId,
        request_id: normalizedParentRequestId,
      })
      .first();

    if (!requestRow) {
      return null;
    }

    candidateRequestRows = await loadCandidateRequestRows();
    candidateRequestIds = collectPromptSelectionCandidateRequestIds(candidateRequestRows, normalizedParentRequestId);

    if (candidateRequestIds.length === 0) {
      candidateRequestIds = [normalizedParentRequestId];
    }

    await appendCandidateResponseMessageRows(candidateRequestRows, candidateRequestIds);
  }

  const linkedMessageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
    .where({
      session_id: normalizedSessionId,
    })
    .whereIn('client_request_id', candidateRequestIds)
    .orderBy('created_at', 'desc')
    .orderBy('message_id', 'desc')
    .orderBy('id', 'desc')
    .limit(Math.max(12, candidateRequestIds.length * 4));

  appendCandidateMessageRows(linkedMessageRows);

  if (candidateMessageRows.length === 0) {
    const recentPromptMessageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
      .where({
        session_id: normalizedSessionId,
      })
      .andWhere('parts_json', 'like', '%"type":"prompt"%')
      .orderBy('created_at', 'desc')
      .orderBy('message_id', 'desc')
      .orderBy('id', 'desc')
      .limit(24);

    appendCandidateMessageRows(recentPromptMessageRows);
  }

  const matchedCandidate = selectChatPromptSelectionMessageCandidate(candidateMessageRows, selection, resolvedAt);

  if (!matchedCandidate) {
    return null;
  }

  const { messageRow, nextParts } = matchedCandidate;
  const resolvedMessageId = Number(messageRow.message_id ?? messageRow.id ?? 0);

  if (!resolvedMessageId) {
    return null;
  }

  await db(CHAT_CONVERSATION_MESSAGE_TABLE)
    .where({
      session_id: normalizedSessionId,
      message_id: resolvedMessageId,
    })
    .update({
      parts_json: stringifyChatMessageParts(nextParts),
    });

  await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({
      session_id: normalizedSessionId,
      request_id: normalizedParentRequestId,
    })
    .update({
      response_message_id: requestRow.response_message_id ?? resolvedMessageId,
      response_text: String(requestRow.response_text ?? '').trim() || String(messageRow.text ?? ''),
      manual_prompt_completed_at: requestRow.manual_prompt_completed_at ?? db.fn.now(),
      updated_at: db.fn.now(),
    });

  const [updatedConversation, updatedRequestRow, updatedMessageRow] = await Promise.all([
    db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(),
    db(CHAT_CONVERSATION_REQUEST_TABLE)
      .where({
        session_id: normalizedSessionId,
        request_id: normalizedParentRequestId,
      })
      .first(),
    db(CHAT_CONVERSATION_MESSAGE_TABLE)
      .where({
        session_id: normalizedSessionId,
        message_id: resolvedMessageId,
      })
      .first(),
  ]);

  if (!updatedRequestRow || !updatedMessageRow) {
    return null;
  }

  return {
    request: normalizeStaleRequestItem(mapRequestRow(updatedRequestRow), updatedConversation),
    message: mapMessageRow(updatedMessageRow),
  };
}

export async function repairChatConversationRequestLinks(sessionId?: string | null) {
  await ensureChatConversationTables();

  const normalizedSessionId = sessionId?.trim() || null;
  const sessionRows = normalizedSessionId
    ? [{ session_id: normalizedSessionId }]
    : await db(CHAT_CONVERSATION_REQUEST_TABLE).distinct('session_id').orderBy('session_id', 'asc');

  let repairedRequestCount = 0;
  let linkedMessageCount = 0;
  let completedStatusCount = 0;
  const touchedSessions: string[] = [];

  for (const sessionRow of sessionRows) {
    const currentSessionId = String(sessionRow.session_id ?? '').trim();

    if (!currentSessionId) {
      continue;
    }

    const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE)
      .where({ session_id: currentSessionId })
      .orderBy('created_at', 'asc')
      .orderBy('request_id', 'asc');
    const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
      .where({ session_id: currentSessionId })
      .select('id', 'message_id', 'author', 'text', 'parts_json', 'client_request_id', 'created_at')
      .orderBy('created_at', 'asc')
      .orderBy('message_id', 'asc')
      .orderBy('id', 'asc');

    let sessionTouched = false;
    const responseMessages: ChatConversationResponseCandidate[] = messageRows.map((row) => ({
      id: Number(row.id ?? 0),
      messageId: Number(row.message_id ?? 0),
      author: String(row.author ?? 'codex') as StoredChatMessage['author'],
      text: String(row.text ?? ''),
      clientRequestId: row.client_request_id == null ? null : String(row.client_request_id),
      createdAt: normalizeDateTimeValue(row.created_at),
    }));

    for (let index = 0; index < requestRows.length; index += 1) {
      const requestRow = requestRows[index];
      const nextRequestRow = requestRows[index + 1];
      const requestId = String(requestRow.request_id ?? '').trim();

      if (!requestId) {
        continue;
      }

      const candidate = selectChatConversationResponseCandidate(
        {
          requestId,
          createdAt: normalizeDateTimeValue(requestRow.created_at) ?? '',
          responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id),
        },
        nextRequestRow
          ? {
              createdAt: normalizeDateTimeValue(nextRequestRow.created_at) ?? '',
            }
          : undefined,
        responseMessages,
      );

      if (!candidate) {
        continue;
      }

      if ((candidate.clientRequestId?.trim() || null) !== requestId) {
        await db(CHAT_CONVERSATION_MESSAGE_TABLE)
          .where({
            session_id: currentSessionId,
            message_id: candidate.messageId,
          })
          .update({
            client_request_id: requestId,
          });
        candidate.clientRequestId = requestId;
        linkedMessageCount += 1;
        sessionTouched = true;
      }

      const shouldPromoteToCompleted =
        !isTerminalRequestStatus(String(requestRow.status ?? '') as ChatConversationRequestStatus) &&
        requestRow.terminal_at != null;
      const nextStatus = shouldPromoteToCompleted ? 'completed' : undefined;
      const previousStatus = String(requestRow.status ?? '').trim();

      await upsertChatConversationRequest(currentSessionId, {
        requestId,
        status: nextStatus,
        responseMessageId: candidate.messageId,
        responseText: candidate.text,
      });

      if (
        requestRow.response_message_id == null ||
        String(requestRow.response_text ?? '') !== candidate.text ||
        requestRow.answered_at == null
      ) {
        repairedRequestCount += 1;
        sessionTouched = true;
      }

      if (nextStatus === 'completed' && previousStatus !== 'completed') {
        completedStatusCount += 1;
        sessionTouched = true;
      }
    }

    if (sessionTouched) {
      touchedSessions.push(currentSessionId);
    }
  }

  return {
    sessionCount: sessionRows.length,
    touchedSessions,
    repairedRequestCount,
    linkedMessageCount,
    completedStatusCount,
  };
}

export async function deleteUnansweredChatConversationRequest(sessionId: string, requestId: string) {
  const normalizedSessionId = sessionId.trim();
  const normalizedRequestId = requestId.trim();
  const current = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({
      session_id: normalizedSessionId,
      request_id: normalizedRequestId,
    })
    .first();

  if (!current) {
    return { deleted: false, reason: 'not_found' as const };
  }

  const conversation = await db(CHAT_CONVERSATION_TABLE)
    .where({ session_id: normalizedSessionId })
    .first();
  const mapped = normalizeStaleRequestItem(mapRequestRow(current), conversation);

  if (mapped.hasResponse) {
    return { deleted: false, reason: 'answered' as const };
  }

  if (mapped.status === 'queued' || mapped.status === 'started') {
    return { deleted: false, reason: 'active' as const };
  }

  await db.transaction(async (trx) => {
    await trx(CHAT_CONVERSATION_MESSAGE_TABLE)
      .where({
        session_id: normalizedSessionId,
        client_request_id: normalizedRequestId,
      })
      .del();

    await trx(CHAT_CONVERSATION_REQUEST_TABLE)
      .where({
        session_id: normalizedSessionId,
        request_id: normalizedRequestId,
      })
      .del();

    const conversation = await trx(CHAT_CONVERSATION_TABLE)
      .where({ session_id: normalizedSessionId })
      .first();

    if (conversation?.current_request_id === normalizedRequestId) {
      await trx(CHAT_CONVERSATION_TABLE)
        .where({ session_id: normalizedSessionId })
        .update({
          current_request_id: null,
          current_job_status: null,
          current_job_message: null,
          current_queue_size: 0,
          current_status_updated_at: db.fn.now(),
          updated_at: db.fn.now(),
        });
    }
  });

  await refreshConversationPreview(normalizedSessionId);
  return { deleted: true, reason: null as null };
}

export async function cancelUnansweredChatConversationRequest(
  sessionId: string,
  requestId: string,
  statusMessage = '사용자 요청으로 중단된 요청을 취소 처리했습니다.',
) {
  const normalizedSessionId = sessionId.trim();
  const normalizedRequestId = requestId.trim();
  const current = await db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({
      session_id: normalizedSessionId,
      request_id: normalizedRequestId,
    })
    .first();

  if (!current) {
    return { cancelled: false, reason: 'not_found' as const, item: null };
  }

  const conversation = await db(CHAT_CONVERSATION_TABLE)
    .where({ session_id: normalizedSessionId })
    .first();
  const mapped = normalizeStaleRequestItem(mapRequestRow(current), conversation);

  if (mapped.hasResponse) {
    return { cancelled: false, reason: 'answered' as const, item: null };
  }

  if (mapped.status === 'queued' || mapped.status === 'started') {
    return { cancelled: false, reason: 'active' as const, item: null };
  }

  if (mapped.status === 'cancelled' || mapped.status === 'removed') {
    return { cancelled: false, reason: 'already_terminal' as const, item: mapped };
  }

  const item = await upsertChatConversationRequest(normalizedSessionId, {
    requestId: normalizedRequestId,
    status: 'cancelled',
    statusMessage,
  });

  await refreshConversationPreview(normalizedSessionId);
  return { cancelled: Boolean(item), reason: item ? null : ('not_found' as const), item };
}

export async function clearAllChatConversationJobStates() {
  await ensureChatConversationTables();

  await db(CHAT_CONVERSATION_TABLE)
    .whereNotNull('current_job_status')
    .update({
      current_request_id: null,
      current_job_status: null,
      current_job_message: null,
      current_queue_size: 0,
      current_status_updated_at: db.fn.now(),
      updated_at: db.fn.now(),
    });
}

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

  return db.transaction(async (trx) => {
    await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE)
      .where({ session_id: normalizedSessionId })
      .update({
        conversation_deleted_at: db.fn.now(),
        updated_at: db.fn.now(),
      });
    await trx(CHAT_CONVERSATION_CLIENT_TABLE).where({ session_id: normalizedSessionId }).del();
    await trx(CHAT_CONVERSATION_ACTIVITY_TABLE).where({ session_id: normalizedSessionId }).del();
    await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: normalizedSessionId }).del();
    await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: normalizedSessionId }).del();
    const deletedCount = await trx(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).del();
    return deletedCount > 0;
  });
}

export async function clearChatConversationData(sessionId: string, clientId?: string | null) {
  const normalizedSessionId = sessionId.trim();

  await db.transaction(async (trx) => {
    await trx(CHAT_CONVERSATION_ACTIVITY_TABLE).where({ session_id: normalizedSessionId }).del();
    await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: normalizedSessionId }).del();
    await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: normalizedSessionId }).del();
    await trx(CHAT_CONVERSATION_CLIENT_TABLE)
      .where({ session_id: normalizedSessionId })
      .update({
        last_read_response_message_id: null,
        updated_at: db.fn.now(),
      });
    await trx(CHAT_CONVERSATION_TABLE)
      .where({ session_id: normalizedSessionId })
      .update({
        current_request_id: null,
        current_job_status: null,
        current_job_message: null,
        current_queue_size: 0,
        current_status_updated_at: null,
        draft_text: '',
        last_message_preview: '',
        last_message_at: null,
        updated_at: db.fn.now(),
      });
  });

  return getChatConversation(normalizedSessionId, clientId);
}

export async function getChatConversationClientPreference(sessionId: string, clientId: string) {
  const row = await db(CHAT_CONVERSATION_CLIENT_TABLE)
    .where({
      session_id: sessionId.trim(),
      client_id: clientId.trim(),
    })
    .first();

  return row ? mapClientPreferenceRow(row) : null;
}

async function listRegisteredNotificationClientIds(clientIds: string[]) {
  const normalizedClientIds = [...new Set(clientIds.map((item) => String(item ?? '').trim()).filter(Boolean))];

  if (!normalizedClientIds.length) {
    return new Set<string>();
  }

  const [webPushRows, tokenRows] = await Promise.all([
    db(WEB_PUSH_SUBSCRIPTION_TABLE)
      .where({ is_enabled: true })
      .andWhere((builder) => {
        builder.whereIn('device_id', normalizedClientIds).orWhereIn('client_id', normalizedClientIds);
      })
      .select('device_id', 'client_id'),
    db(NOTIFICATION_TOKEN_TABLE)
      .where({ is_enabled: true })
      .whereIn('device_id', normalizedClientIds)
      .select('device_id'),
  ]);

  return collectRegisteredNotificationClientIds([...webPushRows, ...tokenRows]);
}

export async function cleanupStaleChatConversationOfflineNotificationClients(
  sessionId: string,
  options?: {
    keepClientIds?: Iterable<string | null | undefined>;
  },
) {
  const normalizedSessionId = sessionId.trim();

  if (!normalizedSessionId) {
    return [] as string[];
  }

  const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE)
    .where({
      session_id: normalizedSessionId,
      notify_offline: true,
    })
    .select('client_id');

  const optedInClientIds = rows
    .map((row) => String(row.client_id ?? '').trim())
    .filter(Boolean);
  const registeredClientIds = await listRegisteredNotificationClientIds(optedInClientIds);
  const staleClientIds = selectStaleOfflineNotificationClientIds(
    optedInClientIds.map((clientId) => ({
      clientId,
      notifyOffline: true,
      hasActivePushRegistration: registeredClientIds.has(clientId),
    })),
    options,
  );

  if (!staleClientIds.length) {
    return [] as string[];
  }

  await db(CHAT_CONVERSATION_CLIENT_TABLE)
    .where({ session_id: normalizedSessionId })
    .whereIn('client_id', staleClientIds)
    .update({
      notify_offline: false,
      updated_at: db.fn.now(),
    });

  await cleanupNotificationMessagesForStaleTargetClients({
    sessionId: normalizedSessionId,
    staleClientIds,
  });

  return staleClientIds;
}

export async function listChatConversationOfflineNotificationClientIds(
  sessionId: string,
  options?: {
    keepClientIds?: Iterable<string | null | undefined>;
  },
) {
  await cleanupStaleChatConversationOfflineNotificationClients(sessionId, options);

  const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE)
    .where({
      session_id: sessionId.trim(),
      notify_offline: true,
    })
    .select('client_id');

  return rows
    .map((row) => String(row.client_id ?? '').trim())
    .filter(Boolean);
}

export async function upsertChatConversationClientPreference(sessionId: string, clientId: string, notifyOffline: boolean) {
  const normalizedSessionId = sessionId.trim();
  const normalizedClientId = clientId.trim();
  await db(CHAT_CONVERSATION_CLIENT_TABLE)
    .insert({
      session_id: normalizedSessionId,
      client_id: normalizedClientId,
      notify_offline: notifyOffline,
      last_read_response_message_id: null,
      created_at: db.fn.now(),
      updated_at: db.fn.now(),
    })
    .onConflict(['session_id', 'client_id'])
    .merge({
      notify_offline: notifyOffline,
      updated_at: db.fn.now(),
    });

  if (notifyOffline) {
    await cleanupStaleChatConversationOfflineNotificationClients(normalizedSessionId, {
      keepClientIds: [normalizedClientId],
    });
  }

  return getChatConversationClientPreference(normalizedSessionId, normalizedClientId);
}

export async function markChatConversationResponsesRead(sessionId: string, clientId: string) {
  const normalizedSessionId = sessionId.trim();
  const normalizedClientId = clientId.trim();

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

  const currentConversation = await db(CHAT_CONVERSATION_TABLE)
    .where({ session_id: normalizedSessionId })
    .first();

  if (!currentConversation) {
    return null;
  }

  const latestResponseMessageId = await getLatestResponseMessageId(normalizedSessionId);
  await db(CHAT_CONVERSATION_CLIENT_TABLE)
    .insert({
      session_id: normalizedSessionId,
      client_id: normalizedClientId,
      notify_offline: Boolean(currentConversation.notify_offline),
      last_read_response_message_id: latestResponseMessageId,
      created_at: db.fn.now(),
      updated_at: db.fn.now(),
    })
    .onConflict(['session_id', 'client_id'])
    .merge({
      last_read_response_message_id: latestResponseMessageId,
      updated_at: db.fn.now(),
    });

  await cleanupStaleChatConversationOfflineNotificationClients(normalizedSessionId, {
    keepClientIds: [normalizedClientId],
  });

  return {
    sessionId: normalizedSessionId,
    lastReadResponseMessageId: latestResponseMessageId,
  };
}

export type SharedResourceTokenRequestUsageSummary = {
  sharedResourceTokenId: string;
  requestCount: number;
  completedRequestCount: number;
  totalTokens: number;
  lastUsedAt: string | null;
};

export function buildChatConversationRequestUsageBySharedResourceTokenIdsQuery(tokenIds: string[]) {
  const normalizedTokenIds = Array.from(new Set(tokenIds.map((value) => String(value ?? '').trim()).filter(Boolean)));

  if (normalizedTokenIds.length === 0) {
    return null;
  }

  return db(CHAT_CONVERSATION_REQUEST_TABLE)
    .whereIn('shared_resource_token_id', normalizedTokenIds)
    .groupBy('shared_resource_token_id')
    .select(
      'shared_resource_token_id',
      db.raw('count(*) as request_count'),
      db.raw('sum(COALESCE(total_tokens, 0)) as total_tokens'),
      db.raw(`sum(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_request_count`),
      db.raw('max(COALESCE(answered_at, terminal_at, updated_at, created_at)) as last_used_at'),
    );
}

export async function listChatConversationRequestUsageBySharedResourceTokenIds(tokenIds: string[]) {
  const usageSummaryQuery = buildChatConversationRequestUsageBySharedResourceTokenIdsQuery(tokenIds);

  if (!usageSummaryQuery) {
    return [] as SharedResourceTokenRequestUsageSummary[];
  }

  await ensureChatConversationTables();

  const rows = await usageSummaryQuery as Array<{
    shared_resource_token_id: string | null;
    request_count: string | number | null;
    completed_request_count: string | number | null;
    total_tokens: string | number | null;
    last_used_at: string | Date | null;
  }>;

  return rows.map((row) => ({
    sharedResourceTokenId: String(row.shared_resource_token_id ?? '').trim(),
    requestCount: Math.max(0, Number(row.request_count ?? 0) || 0),
    completedRequestCount: Math.max(
      0,
      Number(row.completed_request_count ?? 0) || 0,
    ),
    totalTokens: Math.max(0, Number(row.total_tokens ?? 0) || 0),
    lastUsedAt: normalizeDateTimeValue(row.last_used_at),
  }));
}

export async function assignSharedResourceTokenToRequests(sessionId: string, requestIds: string[], sharedResourceTokenId: string) {
  const normalizedSessionId = sessionId.trim();
  const normalizedTokenId = sharedResourceTokenId.trim();
  const normalizedRequestIds = Array.from(new Set(requestIds.map((value) => String(value ?? '').trim()).filter(Boolean)));

  if (!normalizedSessionId || !normalizedTokenId || normalizedRequestIds.length === 0) {
    return 0;
  }

  await ensureChatConversationTables();

  return db(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({ session_id: normalizedSessionId })
    .whereIn('request_id', normalizedRequestIds)
    .update({
      shared_resource_token_id: normalizedTokenId,
      updated_at: db.fn.now(),
    });
}

export async function clearSharedResourceTokenFromRequests(sharedResourceTokenId: string, trx: typeof db = db) {
  const normalizedTokenId = sharedResourceTokenId.trim();

  if (!normalizedTokenId) {
    return 0;
  }

  await ensureChatConversationTables();

  return trx(CHAT_CONVERSATION_REQUEST_TABLE)
    .where({ shared_resource_token_id: normalizedTokenId })
    .update({
      shared_resource_token_id: null,
      updated_at: db.fn.now(),
    });
}
