import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('./logger.js', () => {
  const mockLogger = {
    debug: vi.fn(),
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
    child: (_bindings?: Record<string, unknown>) => mockLogger,
  };
  return {
    logger: mockLogger,
    createScopedLogger: (_bindings?: Record<string, unknown>) => mockLogger,
  };
});

import { MessageTurnController } from './message-turn-controller.js';
import { logger } from './logger.js';
import type { Channel, RegisteredGroup } from './types.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js';

function makeGroup(): RegisteredGroup {
  return {
    name: 'Test Group',
    folder: 'test-group',
    trigger: '@Andy',
    added_at: new Date().toISOString(),
    requiresTrigger: false,
    agentType: 'claude-code',
  };
}

function makeChannel(): Channel {
  return {
    name: 'discord-review',
    connect: vi.fn().mockResolvedValue(undefined),
    disconnect: vi.fn().mockResolvedValue(undefined),
    isConnected: vi.fn(() => true),
    ownsJid: vi.fn(() => true),
    sendMessage: vi.fn().mockResolvedValue(undefined),
    sendAndTrack: vi.fn().mockResolvedValue('progress-1'),
    editMessage: vi.fn().mockResolvedValue(undefined),
    setTyping: vi.fn().mockResolvedValue(undefined),
    getOutboundAuditMeta: vi.fn(() => ({
      channelName: 'discord-review',
      botUserId: 'bot-review',
      botUsername: 'reviewer-bot',
    })),
  };
}

function makeTurnIdentity(): PairedTurnIdentity {
  return {
    turnId: 'task-1:2026-04-10T14:22:00.000Z:reviewer-turn',
    taskId: 'task-1',
    taskUpdatedAt: '2026-04-10T14:22:00.000Z',
    intentKind: 'reviewer-turn',
    role: 'reviewer',
  };
}

async function flushAsync(): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, 0));
}

function getAuditEntries(): Array<Record<string, unknown>> {
  return vi
    .mocked(logger.info)
    .mock.calls.map(([payload]) => payload)
    .filter(
      (payload): payload is Record<string, unknown> =>
        !!payload && typeof payload === 'object' && 'auditEvent' in payload,
    );
}

describe('MessageTurnController outbound audit logging', () => {
  beforeEach(() => {
    vi.resetAllMocks();
  });

  it('logs outbound progress and final audit context with role/service/turn metadata', async () => {
    const channel = makeChannel();
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-review-1',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      deliveryRole: 'reviewer',
      deliveryServiceId: 'codex-review',
      pairedTurnIdentity: makeTurnIdentity(),
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '첫 진행 상황',
    } as any);
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '둘째 진행 상황',
    } as any);
    await flushAsync();
    await controller.handleOutput({
      status: 'success',
      phase: 'final',
      result: '최종 답변',
    } as any);
    await controller.finish('success');

    expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
    expect(channel.sendAndTrack).toHaveBeenCalledWith(
      'dc:test-room',
      expect.stringContaining('첫 진행 상황'),
    );
    expect(channel.editMessage).not.toHaveBeenCalled();
    expect(deliverFinalText).toHaveBeenCalledWith('최종 답변', {
      replaceMessageId: 'progress-1',
    });

    expect(getAuditEntries()).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          auditEvent: 'progress-create',
          chatJid: 'dc:test-room',
          runId: 'run-review-1',
          deliveryRole: 'reviewer',
          serviceId: 'codex-review',
          turnId: 'task-1:2026-04-10T14:22:00.000Z:reviewer-turn',
          turnRole: 'reviewer',
          channelName: 'discord-review',
          botUserId: 'bot-review',
          botUsername: 'reviewer-bot',
          messageId: 'progress-1',
        }),
        expect.objectContaining({
          auditEvent: 'final-delivery-attempt',
          deliveryRole: 'reviewer',
          serviceId: 'codex-review',
          turnId: 'task-1:2026-04-10T14:22:00.000Z:reviewer-turn',
          messageId: 'progress-1',
          deliveryMode: 'edit',
        }),
        expect.objectContaining({
          auditEvent: 'final-delivery-result',
          deliveryRole: 'reviewer',
          serviceId: 'codex-review',
          turnId: 'task-1:2026-04-10T14:22:00.000Z:reviewer-turn',
          messageId: 'progress-1',
          deliveryMode: 'edit',
          delivered: true,
        }),
      ]),
    );
  });

  it('dispose() stops the progress ticker so an aborted turn leaves no zombie edits', async () => {
    vi.useFakeTimers();
    try {
      const channel = makeChannel();
      const controller = new MessageTurnController({
        chatJid: 'dc:test-room',
        group: makeGroup(),
        runId: 'run-zombie-1',
        channel,
        idleTimeout: 1_000,
        failureFinalText: '실패',
        isClaudeCodeAgent: true,
        clearSession: vi.fn(),
        requestClose: vi.fn(),
        deliverFinalText: vi.fn().mockResolvedValue(true),
        deliveryRole: 'reviewer',
        deliveryServiceId: 'codex-review',
        pairedTurnIdentity: makeTurnIdentity(),
      });

      await controller.start();
      // Subagent tool-activity creates the tracked progress message and starts
      // the 5s ticker.
      await controller.handleOutput({
        status: 'success',
        phase: 'tool-activity',
        agentId: 'sub-1',
        result: '작업 중',
      } as any);
      // Flush the async progress-message creation (.then → progressMessageId).
      await vi.advanceTimersByTimeAsync(0);

      // Ticker is live: advancing past the 5s interval edits the message.
      await vi.advanceTimersByTimeAsync(5_000);
      expect(vi.mocked(channel.editMessage)).toHaveBeenCalled();

      // Abnormal exit: finish() is NEVER called (agent threw / turn aborted).
      // The caller's finally must call dispose().
      controller.dispose();

      const editsAfterDispose = vi.mocked(channel.editMessage!).mock.calls
        .length;
      await vi.advanceTimersByTimeAsync(30_000);

      // No further edits — the orphaned "stuck at 0s" zombie ticker is gone.
      expect(vi.mocked(channel.editMessage!).mock.calls.length).toBe(
        editsAfterDispose,
      );

      // Idempotent: calling dispose() again is a no-op and does not throw.
      expect(() => controller.dispose()).not.toThrow();
    } finally {
      vi.useRealTimers();
    }
  });

  it('logs fallback progress audit when tracked message creation fails', async () => {
    const channel = makeChannel();
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    vi.mocked(channel.sendAndTrack!).mockRejectedValueOnce(
      new Error('send failed'),
    );

    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-review-fallback',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      deliveryRole: 'reviewer',
      deliveryServiceId: 'codex-review',
      pairedTurnIdentity: makeTurnIdentity(),
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '첫 진행 상황',
    } as any);
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '둘째 진행 상황',
    } as any);
    await flushAsync();
    await controller.finish('success');

    expect(channel.sendMessage).toHaveBeenCalledWith(
      'dc:test-room',
      expect.stringContaining('첫 진행 상황'),
    );
    expect(getAuditEntries()).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          auditEvent: 'progress-fallback-send',
          chatJid: 'dc:test-room',
          runId: 'run-review-fallback',
          deliveryRole: 'reviewer',
          serviceId: 'codex-review',
          turnId: 'task-1:2026-04-10T14:22:00.000Z:reviewer-turn',
          channelName: 'discord-review',
          botUserId: 'bot-review',
          messageId: null,
          fallbackReason: 'tracked-send-error',
        }),
      ]),
    );
  });

  it('does not replay the last progress message as a final delivery for paired reviewer turns', async () => {
    const channel = makeChannel();
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-review-no-fake-final',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      allowProgressReplayWithoutFinal: false,
      deliveryRole: 'reviewer',
      deliveryServiceId: 'codex-review',
      pairedTurnIdentity: makeTurnIdentity(),
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '확인하겠습니다.',
    } as any);
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '근거를 다시 대조 중입니다.',
    } as any);
    await flushAsync();

    const finishResult = await controller.finish('success');

    expect(finishResult.visiblePhase).toBe('progress');
    expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
    expect(channel.editMessage).toHaveBeenCalledWith(
      'dc:test-room',
      'progress-1',
      expect.stringContaining('확인하겠습니다.'),
    );
    expect(deliverFinalText).not.toHaveBeenCalled();
    expect(getAuditEntries()).not.toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          auditEvent: 'final-delivery-attempt',
        }),
      ]),
    );
  });

  it('does not flush pending progress before final delivery for paired reviewer turns', async () => {
    const channel = makeChannel();
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-review-no-pending-flush',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      deliveryRole: 'reviewer',
      deliveryServiceId: 'codex-review',
      pairedTurnIdentity: makeTurnIdentity(),
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '오너가 대화와 관련 코드 근거를 대조해서 판정을 내리겠습니다.',
    } as any);

    await controller.handleOutput({
      status: 'success',
      phase: 'final',
      result: 'PROCEED 근거를 확인했습니다.',
    } as any);
    await controller.finish('success');

    expect(channel.sendAndTrack).not.toHaveBeenCalled();
    expect(channel.sendMessage).not.toHaveBeenCalled();
    expect(deliverFinalText).toHaveBeenCalledTimes(1);
    expect(deliverFinalText).toHaveBeenCalledWith(
      'PROCEED 근거를 확인했습니다.',
      {
        replaceMessageId: null,
      },
    );
  });

  it('passes structured final attachments to final delivery', async () => {
    const channel = makeChannel();
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const attachments = [
      {
        path: '/tmp/e2e-screenshot.png',
        name: 'e2e-screenshot.png',
        mime: 'image/png',
      },
    ];
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-review-attachments',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      deliveryRole: 'reviewer',
      deliveryServiceId: 'codex-review',
      pairedTurnIdentity: makeTurnIdentity(),
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'final',
      result: '스크린샷을 첨부했습니다.',
      output: {
        visibility: 'public',
        text: '스크린샷을 첨부했습니다.',
        attachments,
      },
    } as any);
    await controller.finish('success');

    expect(deliverFinalText).toHaveBeenCalledWith('스크린샷을 첨부했습니다.', {
      replaceMessageId: null,
      attachments,
    });
    expect(getAuditEntries()).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          auditEvent: 'final-delivery-attempt',
          attachmentCount: 1,
        }),
      ]),
    );
  });

  it('sends final attachments as a fresh final message instead of replacing text-only progress', async () => {
    const channel = {
      ...makeChannel(),
      name: 'discord',
    } satisfies Channel;
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const attachments = [
      {
        path: '/tmp/ejclaw-discord-image-final.png',
        name: 'final.png',
        mime: 'image/png',
      },
    ];
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-owner-final-attachment-send',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      deliveryRole: 'owner',
      pairedTurnIdentity: {
        turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn',
        taskId: 'task-1',
        taskUpdatedAt: '2026-04-10T14:22:00.000Z',
        intentKind: 'finalize-owner-turn',
        role: 'owner',
      },
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '이미지 렌더링 중',
    } as any);
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '이미지 파일 쓰는 중',
    } as any);
    await flushAsync();
    await controller.handleOutput({
      status: 'success',
      phase: 'final',
      result: '이미지 렌더 완료.',
      output: {
        visibility: 'public',
        text: '이미지 렌더 완료.',
        attachments,
      },
    } as any);
    await controller.finish('success');

    expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
    expect(deliverFinalText).toHaveBeenCalledWith('이미지 렌더 완료.', {
      replaceMessageId: null,
      attachments,
    });
  });

  it('replaces the tracked progress message when an owner final arrives', async () => {
    const channel = {
      ...makeChannel(),
      name: 'discord',
    } satisfies Channel;
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-owner-final-replace',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      deliveryRole: 'owner',
      pairedTurnIdentity: {
        turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn',
        taskId: 'task-1',
        taskUpdatedAt: '2026-04-10T14:22:00.000Z',
        intentKind: 'finalize-owner-turn',
        role: 'owner',
      },
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '첫 진행 상황',
    } as any);
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '둘째 진행 상황',
    } as any);
    await flushAsync();
    await controller.handleOutput({
      status: 'success',
      phase: 'final',
      result: 'DONE 최종 답변',
    } as any);
    await controller.finish('success');

    expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
    expect(deliverFinalText).toHaveBeenCalledWith('DONE 최종 답변', {
      replaceMessageId: 'progress-1',
    });
  });

  it('replaces the tracked progress message when finish() replays the last owner progress as final', async () => {
    const channel = {
      ...makeChannel(),
      name: 'discord',
    } satisfies Channel;
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-owner-progress-replay',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      deliveryRole: 'owner',
      pairedTurnIdentity: {
        turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn',
        taskId: 'task-1',
        taskUpdatedAt: '2026-04-10T14:22:00.000Z',
        intentKind: 'finalize-owner-turn',
        role: 'owner',
      },
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '첫 진행 상황',
    } as any);
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '둘째 진행 상황',
    } as any);
    await flushAsync();

    await controller.finish('success');

    expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
    expect(deliverFinalText).toHaveBeenCalledWith('첫 진행 상황', {
      replaceMessageId: 'progress-1',
    });
  });

  it('suppresses replaying the last progress update as final when final delivery is disallowed', async () => {
    const channel = makeChannel();
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-stale-owner-attempt',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      canDeliverFinalText: () => false,
      allowProgressReplayWithoutFinal: true,
      deliveryRole: 'owner',
      pairedTurnIdentity: {
        turnId: 'paired-task:2026-04-10T00:00:00.000Z:finalize-owner-turn',
        taskId: 'paired-task',
        taskUpdatedAt: '2026-04-10T00:00:00.000Z',
        intentKind: 'finalize-owner-turn',
        role: 'owner',
      },
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '작업 중 1',
      output: { visibility: 'public', text: '작업 중 1' },
    } as any);
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '작업 중 2',
      output: { visibility: 'public', text: '작업 중 2' },
    } as any);
    await flushAsync();

    const finishResult = await controller.finish('success');

    expect(finishResult.deliverySucceeded).toBe(true);
    expect(channel.sendAndTrack).not.toHaveBeenCalled();
    expect(channel.sendMessage).not.toHaveBeenCalled();
    expect(deliverFinalText).not.toHaveBeenCalled();
    expect(getAuditEntries()).not.toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          auditEvent: 'final-delivery-attempt',
        }),
      ]),
    );
  });

  it('does not flush buffered progress when final delivery is disallowed for a stale owner turn', async () => {
    const channel = makeChannel();
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-stale-owner-final-with-buffered-progress',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      canDeliverFinalText: () => false,
      deliveryRole: 'owner',
      pairedTurnIdentity: {
        turnId: 'paired-task:2026-04-10T00:00:00.000Z:finalize-owner-turn',
        taskId: 'paired-task',
        taskUpdatedAt: '2026-04-10T00:00:00.000Z',
        intentKind: 'finalize-owner-turn',
        role: 'owner',
      },
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '버퍼된 진행 상황',
      output: { visibility: 'public', text: '버퍼된 진행 상황' },
    } as any);

    await controller.handleOutput({
      status: 'success',
      phase: 'final',
      result: 'DONE 최종 답변',
      output: { visibility: 'public', text: 'DONE 최종 답변' },
    } as any);

    const finishResult = await controller.finish('success');

    expect(finishResult.deliverySucceeded).toBe(true);
    expect(channel.sendAndTrack).not.toHaveBeenCalled();
    expect(channel.sendMessage).not.toHaveBeenCalled();
    expect(deliverFinalText).not.toHaveBeenCalled();
  });

  it('does not emit a tracked progress message when a stale owner turn buffers multiple progress updates', async () => {
    const channel = makeChannel();
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-stale-owner-progress-buffer',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      canDeliverFinalText: () => false,
      deliveryRole: 'owner',
      pairedTurnIdentity: {
        turnId: 'paired-task:2026-04-10T00:00:00.000Z:owner-turn',
        taskId: 'paired-task',
        taskUpdatedAt: '2026-04-10T00:00:00.000Z',
        intentKind: 'owner-turn',
        role: 'owner',
      },
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '첫 진행 상황',
      output: { visibility: 'public', text: '첫 진행 상황' },
    } as any);
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '둘째 진행 상황',
      output: { visibility: 'public', text: '둘째 진행 상황' },
    } as any);

    const finishResult = await controller.finish('success');

    expect(finishResult.deliverySucceeded).toBe(true);
    expect(channel.sendAndTrack).not.toHaveBeenCalled();
    expect(channel.sendMessage).not.toHaveBeenCalled();
    expect(deliverFinalText).not.toHaveBeenCalled();
  });

  it('does not edit an existing tracked progress message after a stale owner turn loses delivery ownership', async () => {
    const channel = makeChannel();
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    let canDeliver = true;
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-stale-owner-progress-edit',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      canDeliverFinalText: () => canDeliver,
      deliveryRole: 'owner',
      pairedTurnIdentity: {
        turnId: 'paired-task:2026-04-10T00:00:00.000Z:owner-turn',
        taskId: 'paired-task',
        taskUpdatedAt: '2026-04-10T00:00:00.000Z',
        intentKind: 'owner-turn',
        role: 'owner',
      },
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '첫 진행 상황',
      output: { visibility: 'public', text: '첫 진행 상황' },
    } as any);
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '둘째 진행 상황',
      output: { visibility: 'public', text: '둘째 진행 상황' },
    } as any);
    await flushAsync();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '셋째 진행 상황',
      output: { visibility: 'public', text: '셋째 진행 상황' },
    } as any);
    await flushAsync();

    expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
    expect(channel.editMessage).toHaveBeenCalledTimes(1);

    canDeliver = false;
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '넷째 진행 상황',
      output: { visibility: 'public', text: '넷째 진행 상황' },
    } as any);
    await flushAsync();

    const finishResult = await controller.finish('success');

    expect(finishResult.deliverySucceeded).toBe(true);
    expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
    expect(channel.editMessage).toHaveBeenCalledTimes(1);
    expect(channel.sendMessage).not.toHaveBeenCalled();
    expect(deliverFinalText).not.toHaveBeenCalled();
  });

  it('replaces the tracked progress message when finish() publishes a failure final', async () => {
    const channel = {
      ...makeChannel(),
      name: 'discord',
    } satisfies Channel;
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-owner-failure-final',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      deliveryRole: 'owner',
      pairedTurnIdentity: {
        turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn',
        taskId: 'task-1',
        taskUpdatedAt: '2026-04-10T14:22:00.000Z',
        intentKind: 'finalize-owner-turn',
        role: 'owner',
      },
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '첫 진행 상황',
    } as any);
    await controller.handleOutput({
      status: 'error',
      phase: 'progress',
      result: '오류 직전 진행 상황',
    } as any);
    await flushAsync();

    await controller.finish('error');

    expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
    expect(deliverFinalText).toHaveBeenCalledWith('실패', {
      replaceMessageId: 'progress-1',
    });
  });

  it('publishes a failure final when an error finishes before any visible output', async () => {
    const channel = makeChannel();
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-silent-error-final',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      deliveryRole: 'owner',
    });

    await controller.start();
    const finishResult = await controller.finish('error');

    expect(finishResult.visiblePhase).toBe('final');
    expect(deliverFinalText).toHaveBeenCalledWith('실패', {
      replaceMessageId: null,
    });
  });
});

describe('MessageTurnController human interruptions', () => {
  beforeEach(() => {
    vi.resetAllMocks();
  });

  it('does not replay owner progress as final when a human message interrupted the turn', async () => {
    const channel = { ...makeChannel(), name: 'discord' } satisfies Channel;
    const deliverFinalText = vi.fn().mockResolvedValue(true);
    const controller = new MessageTurnController({
      chatJid: 'dc:test-room',
      group: makeGroup(),
      runId: 'run-owner-human-interrupted',
      channel,
      idleTimeout: 1_000,
      failureFinalText: '실패',
      isClaudeCodeAgent: true,
      clearSession: vi.fn(),
      requestClose: vi.fn(),
      deliverFinalText,
      getCloseReason: () => 'human-message-detected',
      deliveryRole: 'owner',
      pairedTurnIdentity: {
        turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn',
        taskId: 'task-1',
        taskUpdatedAt: '2026-04-10T14:22:00.000Z',
        intentKind: 'owner-turn',
        role: 'owner',
      },
    });

    await controller.start();
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '아비터 판단을 내리겠습니다.',
    } as any);
    await controller.handleOutput({
      status: 'success',
      phase: 'progress',
      result: '추가 확인 중입니다.',
    } as any);
    await flushAsync();

    await controller.finish('success');

    expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
    expect(deliverFinalText).not.toHaveBeenCalled();
  });
});
