import test from 'node:test';
import assert from 'node:assert/strict';
import {
  CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
  CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES,
  applyChatPromptSelectionPatch,
  buildChatConversationRequestUsageBySharedResourceTokenIdsQuery,
  buildChatConversationContextUpdateFields,
  buildChatPromptTargetSignature,
  buildChatConversationRequestPatchFromMessage,
  collectPromptSelectionCandidateRequestIds,
  collectRegisteredNotificationClientIds,
  hasMeaningfulChatSourceArtifacts,
  inferSourceChangeScreenTitle,
  isManagedChatShareSessionId,
  isVisibleConversationMessage,
  mergeChatConversationRequestStatus,
  normalizeStaleRequestItem,
  selectStaleOfflineNotificationClientIds,
  resolveNextConversationContextValue,
  resolveNextConversationChatTypeId,
  hasPendingAttentionVerificationRequest,
  isConversationAttentionPending,
  shouldClearConversationJobState,
  selectChatConversationResponseCandidate,
} from './chat-room-service.js';

test('mergeChatConversationRequestStatus keeps terminal states from being downgraded', () => {
  assert.equal(mergeChatConversationRequestStatus('completed', 'accepted'), 'completed');
  assert.equal(mergeChatConversationRequestStatus('started', 'accepted'), 'started');
  assert.equal(mergeChatConversationRequestStatus('queued', 'accepted'), 'queued');
  assert.equal(mergeChatConversationRequestStatus('accepted', 'completed'), 'completed');
});

test('resolveNextConversationChatTypeId prefers the requested chat type over the stored one', () => {
  assert.equal(resolveNextConversationChatTypeId('general-request', 'api-request-template'), 'api-request-template');
  assert.equal(resolveNextConversationChatTypeId('general-request', '  api-request-template  '), 'api-request-template');
});

test('resolveNextConversationChatTypeId falls back to the stored chat type when the request is empty', () => {
  assert.equal(resolveNextConversationChatTypeId('general-request', null), 'general-request');
  assert.equal(resolveNextConversationChatTypeId('general-request', '   '), 'general-request');
  assert.equal(resolveNextConversationChatTypeId(null, null), null);
});

test('resolveNextConversationContextValue prefers the requested chat type context', () => {
  assert.equal(resolveNextConversationContextValue('old context', 'new context', true), 'new context');
  assert.equal(resolveNextConversationContextValue('old context', '   ', true), null);
  assert.equal(resolveNextConversationContextValue(null, 'new context', true), 'new context');
  assert.equal(resolveNextConversationContextValue('old context', undefined, false), 'old context');
});

test('isManagedChatShareSessionId detects managed shared chat rooms only', () => {
  assert.equal(isManagedChatShareSessionId('chat-share-room-mb2p1-1234abcd'), true);
  assert.equal(isManagedChatShareSessionId('chat-room-mb2p1-1234abcd'), false);
  assert.equal(isManagedChatShareSessionId(''), false);
});

test('buildChatConversationContextUpdateFields leaves title untouched when unrelated metadata changes', () => {
  assert.deepEqual(
    buildChatConversationContextUpdateFields({
      current: {
        title: '사용자 저장 제목',
        chat_type_id: 'codex-live',
        last_chat_type_id: 'codex-live',
        client_id: 'client-1',
        notify_offline: true,
      },
      payload: {
        clientId: 'client-1',
        codexModel: 'gpt-5.4',
      },
    }),
    {
      codex_model: 'gpt-5.4',
    },
  );
});

test('buildChatConversationContextUpdateFields ignores undefined payload keys so title is not reset', () => {
  assert.deepEqual(
    buildChatConversationContextUpdateFields({
      current: {
        title: '브라우저 실검증 제목',
        request_badge_label: '기존 배지',
        chat_type_id: 'codex-live',
        last_chat_type_id: 'codex-live',
        client_id: 'client-1',
        notify_offline: true,
      },
      payload: {
        title: undefined,
        requestBadgeLabel: '새 배지',
      },
    }),
    {
      request_badge_label: '새 배지',
    },
  );
});

test('buildChatConversationContextUpdateFields updates shared room chat type metadata together', () => {
  assert.deepEqual(
    buildChatConversationContextUpdateFields({
      current: {
        title: '공유채팅',
        chat_type_id: 'general-request',
        last_chat_type_id: 'general-request',
        context_label: '일반 요청',
        context_description: 'old',
        notify_offline: true,
      },
      payload: {
        chatTypeId: 'codex-live-default',
        lastChatTypeId: 'codex-live-default',
        contextLabel: 'Codex Live 기본',
        contextDescription: null,
      },
    }),
    {
      chat_type_id: 'codex-live-default',
      last_chat_type_id: 'codex-live-default',
      context_label: 'Codex Live 기본',
      context_description: null,
    },
  );
});

test('buildChatConversationContextUpdateFields writes global notify flag only when no client is bound', () => {
  assert.deepEqual(
    buildChatConversationContextUpdateFields({
      current: {
        title: '공유채팅',
        chat_type_id: 'general-request',
        last_chat_type_id: 'general-request',
        notify_offline: false,
      },
      payload: {
        notifyOffline: true,
      },
    }),
    {
      notify_offline: true,
    },
  );

  assert.deepEqual(
    buildChatConversationContextUpdateFields({
      current: {
        title: '공유채팅',
        chat_type_id: 'general-request',
        last_chat_type_id: 'general-request',
        client_id: 'client-1',
        notify_offline: false,
      },
      payload: {
        clientId: 'client-1',
        notifyOffline: true,
      },
    }),
    {},
  );
});

test('selectStaleOfflineNotificationClientIds keeps current or registered clients and only selects orphaned opt-ins', () => {
  assert.deepEqual(
    selectStaleOfflineNotificationClientIds(
      [
        {
          clientId: 'client-current',
          notifyOffline: true,
          hasActivePushRegistration: false,
        },
        {
          clientId: 'client-registered',
          notifyOffline: true,
          hasActivePushRegistration: true,
        },
        {
          clientId: 'client-stale',
          notifyOffline: true,
          hasActivePushRegistration: false,
        },
        {
          clientId: 'client-disabled',
          notifyOffline: false,
          hasActivePushRegistration: false,
        },
      ],
      {
        keepClientIds: ['client-current'],
      },
    ),
    ['client-stale'],
  );
});

test('collectRegisteredNotificationClientIds keeps both web push client ids and device ids', () => {
  assert.deepEqual(
    Array.from(
      collectRegisteredNotificationClientIds([
        {
          device_id: 'web-device-1',
          client_id: 'client-preview-1',
        },
        {
          device_id: 'ios-device-1',
        },
      ]),
    ).sort(),
    ['client-preview-1', 'ios-device-1', 'web-device-1'],
  );
});

test('hasPendingAttentionVerificationRequest keeps 일반 답변 in pending attention until manual completion', () => {
  assert.equal(
    hasPendingAttentionVerificationRequest(
      {
        status: 'completed',
        responseMessageId: 101,
        responseText: '일반 답변입니다.',
        requestOrigin: 'composer',
      },
      [],
    ),
    true,
  );

  assert.equal(
    hasPendingAttentionVerificationRequest(
      {
        status: 'completed',
        responseMessageId: null,
        responseText: '',
        requestOrigin: 'composer',
      },
      [
        {
          author: 'codex',
          text: '```diff\n+ hello\n```',
        },
      ],
    ),
    true,
  );

  assert.equal(
    hasPendingAttentionVerificationRequest(
      {
        status: 'completed',
        responseMessageId: null,
        responseText: '',
        requestOrigin: 'composer',
      },
      [
        {
          author: 'codex',
          text: '짧은 진행 로그',
        },
      ],
    ),
    false,
  );
});

test('isConversationAttentionPending clears verification attention when 답변하기 child request exists', () => {
  assert.equal(
    isConversationAttentionPending({
      request: {
        sessionId: 'session-1',
        requestId: 'parent-request',
        requesterClientId: null,
        chatTypeId: null,
        chatTypeLabel: '기본처리',
        requestOrigin: 'composer',
        sharedResourceTokenId: null,
        parentRequestId: null,
        promptContextRef: null,
        status: 'completed',
        statusMessage: null,
        retryCount: 0,
        userMessageId: 1,
        userText: '원본 질문',
        responseMessageId: 2,
        responseText: '원본 답변',
        usageSnapshot: null,
        totalTokens: null,
        hasResponse: true,
        canDelete: false,
        manualPromptCompletedAt: null,
        manualVerificationCompletedAt: null,
        createdAt: '2026-05-26T00:00:00.000Z',
        updatedAt: '2026-05-26T00:01:00.000Z',
        answeredAt: '2026-05-26T00:01:00.000Z',
        terminalAt: '2026-05-26T00:01:00.000Z',
      },
      relatedMessages: [
        {
          id: 2,
          author: 'codex',
          text: '원본 답변',
          timestamp: '2026-05-26T00:01:00.000Z',
        },
      ],
      childRequestCountByParentId: new Map([['parent-request', 1]]),
      promptFollowupCountByParentId: new Map(),
    }),
    false,
  );
});

test('isConversationAttentionPending keeps completed 일반 답변 visible when no child request exists', () => {
  assert.equal(
    isConversationAttentionPending({
      request: {
        sessionId: 'session-1',
        requestId: 'standalone-request',
        requesterClientId: null,
        chatTypeId: null,
        chatTypeLabel: '기본처리',
        requestOrigin: 'composer',
        sharedResourceTokenId: null,
        parentRequestId: null,
        promptContextRef: null,
        status: 'completed',
        statusMessage: null,
        retryCount: 0,
        userMessageId: 1,
        userText: '독립 질문',
        responseMessageId: 2,
        responseText: '독립 답변',
        usageSnapshot: null,
        totalTokens: null,
        hasResponse: true,
        canDelete: false,
        manualPromptCompletedAt: null,
        manualVerificationCompletedAt: null,
        createdAt: '2026-05-26T00:00:00.000Z',
        updatedAt: '2026-05-26T00:01:00.000Z',
        answeredAt: '2026-05-26T00:01:00.000Z',
        terminalAt: '2026-05-26T00:01:00.000Z',
      },
      relatedMessages: [
        {
          id: 2,
          author: 'codex',
          text: '독립 답변',
          timestamp: '2026-05-26T00:01:00.000Z',
        },
      ],
      childRequestCountByParentId: new Map(),
      promptFollowupCountByParentId: new Map(),
    }),
    true,
  );
});

test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
  assert.equal(
    buildChatConversationRequestPatchFromMessage({
      id: 10,
      author: 'system',
      text: '요청 실행 중입니다.',
      clientRequestId: 'chat-req-1',
    }),
    null,
  );
});

test('applyChatPromptSelectionPatch resolves the matched prompt with persisted selections', () => {
  const promptPart = {
    type: 'prompt' as const,
    title: '다음 단계 선택',
    description: '원하는 작업을 고르세요.',
    submitLabel: '선택 전달',
    mode: 'queue' as const,
    selectedValues: [],
    options: [],
    steps: [
      {
        key: 'scope',
        title: '범위',
        selectedValues: [],
        options: [
          {
            value: 'ui',
            label: 'UI',
          },
          {
            value: 'api',
            label: 'API',
          },
        ],
      },
    ],
  };

  const patched = applyChatPromptSelectionPatch(
    [promptPart],
    {
      promptIndex: 0,
      promptTitle: promptPart.title,
      promptSignature: buildChatPromptTargetSignature(promptPart),
      selectedValues: ['ui'],
      stepSelections: [
        {
          stepKey: 'scope',
          selectedValues: ['ui'],
          freeText: '',
        },
      ],
      summaryText: '범위: UI',
      attachments: [
        {
          id: 'attachment-1',
          name: 'spec.png',
          path: 'public/.codex_chat/test/resource/uploads/spec.png',
          publicUrl: '/api/chat/resources/.codex_chat/test/resource/uploads/spec.png',
          size: 128,
          mimeType: 'image/png',
        },
      ],
    },
    '2026-05-18T08:20:00.000Z',
  );

  assert.ok(patched);
  assert.equal(patched?.[0]?.type, 'prompt');
  assert.deepEqual(patched?.[0]?.selectedValues, ['ui']);
  assert.equal(patched?.[0]?.readOnly, true);
  assert.equal(patched?.[0]?.resolvedBy, 'user');
  assert.equal(patched?.[0]?.resolvedAt, '2026-05-18T08:20:00.000Z');
  assert.equal(patched?.[0]?.resultText, '범위: UI');
  assert.equal(patched?.[0]?.attachments?.[0]?.name, 'spec.png');
  assert.deepEqual(patched?.[0]?.steps?.[0]?.selectedValues, ['ui']);
});

test('applyChatPromptSelectionPatch keeps followup text for free-text-only prompt submissions', () => {
  const promptPart = {
    type: 'prompt' as const,
    title: '다음 단계 선택',
    description: '원하는 작업을 고르세요.',
    submitLabel: '선택 전달',
    mode: 'queue' as const,
    selectedValues: [],
    options: [],
    steps: [],
  };

  const patched = applyChatPromptSelectionPatch(
    [promptPart],
    {
      promptIndex: 0,
      promptTitle: promptPart.title,
      promptSignature: buildChatPromptTargetSignature(promptPart),
      selectedValues: [],
      freeText: '',
      followupText: '선택 없이 기타 요청 기준으로 이어서 진행해 주세요.',
      summaryText: '',
    },
    '2026-05-18T08:25:00.000Z',
  );

  assert.ok(patched);
  assert.equal(patched?.[0]?.type, 'prompt');
  assert.equal(patched?.[0]?.resolvedBy, 'user');
  assert.equal(patched?.[0]?.resultText, '선택 없이 기타 요청 기준으로 이어서 진행해 주세요.');
});

test('collectPromptSelectionCandidateRequestIds includes descendant requests and prefers recent responses first', () => {
  assert.deepEqual(
    collectPromptSelectionCandidateRequestIds(
      [
        {
          request_id: 'root-request',
          parent_request_id: null,
          created_at: '2026-05-18T02:00:00.000Z',
        },
        {
          request_id: 'prompt-child',
          parent_request_id: 'root-request',
          created_at: '2026-05-18T02:01:00.000Z',
        },
        {
          request_id: 'composer-grandchild',
          parent_request_id: 'prompt-child',
          created_at: '2026-05-18T02:02:00.000Z',
        },
        {
          request_id: 'other-root',
          parent_request_id: null,
          created_at: '2026-05-18T02:03:00.000Z',
        },
      ],
      'root-request',
    ),
    ['composer-grandchild', 'prompt-child', 'root-request'],
  );
});

test('CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH allows long chat type guidance text', () => {
  assert.ok(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH >= 2691);
});

test('chat request schema requires chat type columns for codex live persistence', () => {
  assert.equal(CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES.includes('chat_type_id'), true);
  assert.equal(CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES.includes('chat_type_label'), true);
});

test('shared resource token usage summary query keeps aggregate aliases outside function bodies', () => {
  const query = buildChatConversationRequestUsageBySharedResourceTokenIdsQuery(['token-1', 'token-2']);

  assert.ok(query);

  const sql = query?.toSQL().sql ?? '';

  assert.match(sql, /count\(\*\) as request_count/i);
  assert.match(sql, /sum\(COALESCE\(total_tokens, 0\)\) as total_tokens/i);
  assert.match(sql, /sum\(CASE WHEN status = 'completed' THEN 1 ELSE 0 END\) as completed_request_count/i);
  assert.match(sql, /max\(COALESCE\(answered_at, terminal_at, updated_at, created_at\)\) as last_used_at/i);
  assert.doesNotMatch(sql, /END as completed_request_count\)/i);
  assert.doesNotMatch(sql, /created_at\) as last_used_at\)/i);
});

test('isVisibleConversationMessage hides internal system messages and keeps activity logs', () => {
  assert.equal(
    isVisibleConversationMessage({
      id: 1,
      author: 'system',
      text: '응답을 준비하고 있습니다.',
      timestamp: '2026-04-22 10:00:00',
    }),
    false,
  );

  assert.equal(
    isVisibleConversationMessage({
      id: 2,
      author: 'system',
      text: '[[activity-log]]\n작업을 시작했습니다.',
      timestamp: '2026-04-22 10:00:01',
    }),
    true,
  );
});

test('hasMeaningfulChatSourceArtifacts requires real file or diff artifacts', () => {
  assert.equal(
    hasMeaningfulChatSourceArtifacts({
      changedFiles: [],
      currentSourceFiles: [],
      diffBlocks: [],
    }),
    false,
  );

  assert.equal(
    hasMeaningfulChatSourceArtifacts({
      changedFiles: ['src/app/main/ChatSourceChangesPage.tsx'],
      currentSourceFiles: [],
      diffBlocks: [],
    }),
    true,
  );
});

test('inferSourceChangeScreenTitle prefers changed source menu over generic Codex Live title', () => {
  assert.equal(
    inferSourceChangeScreenTitle(
      ['src/app/main/ResourceManagementPage.tsx'],
      'Codex Live / Codex Live',
    ),
    '리소스 관리 / 리소스 관리',
  );

  assert.equal(
    inferSourceChangeScreenTitle(
      ['src/app/main/PreviewAppOverlay.tsx'],
      'Codex Live / Codex Live',
    ),
    'Preview App / 모바일 앱 열기',
  );

  assert.equal(
    inferSourceChangeScreenTitle(
      ['etc/servers/work-server/src/routes/resource-manager.ts'],
      'Codex Live / Codex Live',
    ),
    '리소스 관리 / 리소스 관리',
  );
});

test('inferSourceChangeScreenTitle falls back to docs and stored title when no menu rule matches', () => {
  assert.equal(
    inferSourceChangeScreenTitle(
      ['docs/project/overview.md'],
      'Codex Live / Codex Live',
    ),
    'Docs / 프로젝트 구조',
  );

  assert.equal(
    inferSourceChangeScreenTitle(
      ['scripts/dev/check.sh'],
      '직접 지정 제목',
    ),
    '직접 지정 제목',
  );
});

test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => {
  assert.deepEqual(
    buildChatConversationRequestPatchFromMessage({
      id: 11,
      author: 'user',
      text: '질문',
      clientRequestId: 'chat-req-2',
    }),
    {
      requestId: 'chat-req-2',
      status: 'accepted',
      userMessageId: 11,
      userText: '질문',
    },
  );

  assert.deepEqual(
    buildChatConversationRequestPatchFromMessage({
      id: 12,
      author: 'codex',
      text: '답변',
      clientRequestId: 'chat-req-2',
    }),
    {
      requestId: 'chat-req-2',
      status: 'started',
      responseMessageId: 12,
      responseText: '답변',
    },
  );
});

test('selectChatConversationResponseCandidate falls back to codex replies in the request window', () => {
  const candidate = selectChatConversationResponseCandidate(
    {
      requestId: 'chat-req-3',
      createdAt: '2026-04-18T14:00:00.000Z',
      responseMessageId: null,
    },
    {
      createdAt: '2026-04-18T14:10:00.000Z',
    },
    [
      {
        id: 1,
        messageId: 1001,
        author: 'codex',
        text: '이전 답변',
        clientRequestId: null,
        createdAt: '2026-04-18T13:59:00.000Z',
      },
      {
        id: 2,
        messageId: 1002,
        author: 'codex',
        text: '현재 요청 답변',
        clientRequestId: null,
        createdAt: '2026-04-18T14:05:00.000Z',
      },
      {
        id: 3,
        messageId: 1003,
        author: 'codex',
        text: '다음 요청 답변',
        clientRequestId: null,
        createdAt: '2026-04-18T14:11:00.000Z',
      },
    ],
  );

  assert.deepEqual(candidate, {
    id: 2,
    messageId: 1002,
    author: 'codex',
    text: '현재 요청 답변',
    clientRequestId: null,
    createdAt: '2026-04-18T14:05:00.000Z',
  });
});

test('shouldClearConversationJobState clears stale job state when terminal request already has a response', () => {
  assert.equal(
    shouldClearConversationJobState({
      currentRequestId: 'chat-req-4',
      currentJobStatus: 'started',
      request: {
        requestId: 'chat-req-4',
        status: 'completed',
        responseMessageId: 101,
        responseText: '답변',
        terminalAt: '2026-04-19T01:00:00.000Z',
      },
    }),
    true,
  );
});

test('shouldClearConversationJobState clears orphaned job state without a current request id', () => {
  assert.equal(
    shouldClearConversationJobState({
      currentRequestId: null,
      currentJobStatus: 'started',
      request: null,
    }),
    true,
  );
});

test('shouldClearConversationJobState keeps active job state when request is still running without a response', () => {
  assert.equal(
    shouldClearConversationJobState({
      currentRequestId: 'chat-req-5',
      currentJobStatus: 'started',
      request: {
        requestId: 'chat-req-5',
        status: 'started',
        responseMessageId: null,
        responseText: '',
        terminalAt: null,
      },
    }),
    false,
  );
});

test('shouldClearConversationJobState does not clear placeholder-only started responses', () => {
  assert.equal(
    shouldClearConversationJobState({
      currentRequestId: 'chat-req-6',
      currentJobStatus: 'started',
      request: {
        requestId: 'chat-req-6',
        status: 'started',
        responseMessageId: 301,
        responseText: '응답을 준비하고 있습니다...',
        terminalAt: null,
      },
    }),
    false,
  );
});

test('shouldClearConversationJobState clears stale placeholder-only started responses when runtime is gone', () => {
  assert.equal(
    shouldClearConversationJobState({
      currentRequestId: 'chat-req-7',
      currentJobStatus: 'started',
      currentStatusUpdatedAt: '2026-04-19T08:08:35.813Z',
      runtimeActive: false,
      nowMs: Date.parse('2026-04-19T08:11:36.000Z'),
      request: {
        requestId: 'chat-req-7',
        status: 'started',
        responseMessageId: 302,
        responseText: '응답을 준비하고 있습니다...',
        terminalAt: null,
        updatedAt: '2026-04-19T08:08:56.086Z',
      },
    }),
    true,
  );
});

test('shouldClearConversationJobState keeps placeholder-only started responses while runtime is active', () => {
  assert.equal(
    shouldClearConversationJobState({
      currentRequestId: 'chat-req-8',
      currentJobStatus: 'started',
      currentStatusUpdatedAt: '2026-04-19T08:08:35.813Z',
      runtimeActive: true,
      nowMs: Date.parse('2026-04-19T08:11:36.000Z'),
      request: {
        requestId: 'chat-req-8',
        status: 'started',
        responseMessageId: 303,
        responseText: '응답을 준비하고 있습니다...',
        terminalAt: null,
        updatedAt: '2026-04-19T08:08:56.086Z',
      },
    }),
    false,
  );
});

test('shouldClearConversationJobState clears in-progress state immediately after process restart when runtime is gone', () => {
  assert.equal(
    shouldClearConversationJobState({
      currentRequestId: 'chat-req-9',
      currentJobStatus: 'started',
      currentStatusUpdatedAt: '2026-05-27T00:57:53.000Z',
      runtimeActive: false,
      nowMs: Date.parse('2026-05-27T01:03:10.000Z'),
      processStartedAtMs: Date.parse('2026-05-27T01:03:00.000Z'),
      request: {
        requestId: 'chat-req-9',
        status: 'started',
        responseMessageId: null,
        responseText: '',
        terminalAt: null,
        updatedAt: '2026-05-27T00:58:10.000Z',
      },
    }),
    true,
  );
});

test('normalizeStaleRequestItem keeps queued requests when another request is currently active', () => {
  assert.deepEqual(
    normalizeStaleRequestItem(
      {
        sessionId: 'session-1',
        requestId: 'chat-req-queued',
        requesterClientId: null,
        requestOrigin: null,
        sharedResourceTokenId: null,
        parentRequestId: null,
        promptContextRef: null,
        status: 'queued',
        statusMessage: '대기열 1건',
        retryCount: 0,
        userMessageId: 11,
        userText: '다음 요청',
        responseMessageId: null,
        responseText: '',
        usageSnapshot: null,
        totalTokens: null,
        hasResponse: false,
        canDelete: false,
        createdAt: '2026-05-11T00:00:00.000Z',
        updatedAt: '2026-05-11T00:00:30.000Z',
        answeredAt: null,
        terminalAt: null,
      },
      {
        current_request_id: 'chat-req-running',
        current_job_status: 'started',
        current_status_updated_at: '2026-05-11T00:00:30.000Z',
      },
    ),
      {
        sessionId: 'session-1',
        requestId: 'chat-req-queued',
        requesterClientId: null,
        requestOrigin: null,
        parentRequestId: null,
        status: 'queued',
        statusMessage: '대기열 1건',
        retryCount: 0,
        userMessageId: 11,
        userText: '다음 요청',
        responseMessageId: null,
        responseText: '',
        usageSnapshot: null,
        totalTokens: null,
        hasResponse: false,
        canDelete: false,
        createdAt: '2026-05-11T00:00:00.000Z',
        updatedAt: '2026-05-11T00:00:30.000Z',
        answeredAt: null,
        terminalAt: null,
      },
  );
});

test('normalizeStaleRequestItem marks detached queued requests as failed after process restart when runtime is gone', () => {
  assert.deepEqual(
    normalizeStaleRequestItem(
      {
        sessionId: 'session-1',
        requestId: 'chat-req-detached',
        requesterClientId: null,
        requestOrigin: null,
        sharedResourceTokenId: null,
        parentRequestId: null,
        promptContextRef: null,
        status: 'queued',
        statusMessage: '대기열 1건',
        retryCount: 0,
        userMessageId: 12,
        userText: '끊긴 요청',
        responseMessageId: null,
        responseText: '',
        usageSnapshot: null,
        totalTokens: null,
        hasResponse: false,
        canDelete: false,
        createdAt: '2026-05-27T00:57:53.000Z',
        updatedAt: '2026-05-27T00:58:10.000Z',
        answeredAt: null,
        terminalAt: null,
      },
      {
        current_request_id: 'chat-req-running',
        current_job_status: 'started',
        current_status_updated_at: '2026-05-27T01:03:05.000Z',
      },
      {
        runtimeActive: false,
        nowMs: Date.parse('2026-05-27T01:03:10.000Z'),
        processStartedAtMs: Date.parse('2026-05-27T01:03:00.000Z'),
      },
    ),
    {
      sessionId: 'session-1',
      requestId: 'chat-req-detached',
      requesterClientId: null,
      requestOrigin: null,
      sharedResourceTokenId: null,
      parentRequestId: null,
      promptContextRef: null,
      status: 'failed',
      statusMessage: '대기열 1건',
      retryCount: 0,
      userMessageId: 12,
      userText: '끊긴 요청',
      responseMessageId: null,
      responseText: '',
      usageSnapshot: null,
      totalTokens: null,
      hasResponse: false,
      canDelete: true,
      createdAt: '2026-05-27T00:57:53.000Z',
      updatedAt: '2026-05-27T00:58:10.000Z',
      answeredAt: null,
      terminalAt: '2026-05-27T00:58:10.000Z',
    },
  );
});
