import { describe, it, expect } from 'vitest';
import type { Broker } from '@boocode/server/broker';
import { makeFrameEmitter } from '../frame-emitter.js';
import { makeDcpStreamStripper } from '../dcp-strip.js';
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';

/**
 * makeFrameEmitter (v2.7 audit reshape): the AgentEvent → WS-frame mapping + turn
 * accumulators extracted from AcpStreamContext. Pure-ish over an injected broker.
 */

function fakeBroker(): { broker: Broker; frames: Array<{ sid: string; frame: Record<string, unknown> }> } {
  const frames: Array<{ sid: string; frame: Record<string, unknown> }> = [];
  const broker = {
    publishFrame: (sid: string, frame: unknown) => {
      frames.push({ sid, frame: frame as Record<string, unknown> });
    },
  } as unknown as Broker;
  return { broker, frames };
}

const toolSnap: AcpToolSnapshot = { toolCallId: 'c1', title: 'grep', status: 'completed', rawOutput: 'x' };

describe('makeFrameEmitter — streaming frames', () => {
  it('maps text/reasoning/tool events to delta/reasoning_delta/tool_call frames', () => {
    const { broker, frames } = fakeBroker();
    const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' });

    em.onEvent({ type: 'text', text: 'hello ' });
    em.onEvent({ type: 'reasoning', text: 'mulling' });
    em.onEvent({ type: 'tool_call', toolCall: toolSnap });

    expect(frames.map((f) => f.frame.type)).toEqual(['delta', 'reasoning_delta', 'tool_call']);
    expect(frames[0]!.frame).toMatchObject({ message_id: 'm1', chat_id: 'ch1', content: 'hello ' });
    expect(frames[2]!.frame).toMatchObject({ message_id: 'm1', chat_id: 'ch1' });
    expect(em.output).toBe('hello ');
    expect(em.reasoningText).toBe('mulling');
    expect(em.snapshots).toHaveLength(1);
  });

  it('publishes a tool_call frame for BOTH tool_call and tool_update events', () => {
    const { broker, frames } = fakeBroker();
    const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' });
    em.onEvent({ type: 'tool_update', toolCall: toolSnap });
    expect(frames).toHaveLength(1);
    expect(frames[0]!.frame.type).toBe('tool_call');
  });

  it('publishes an agent_commands frame and merges the command cache', () => {
    const { broker, frames } = fakeBroker();
    const taskId = `task-fe-${Math.floor(performance.now())}-${frames.length}`;
    const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1', taskId });
    em.onEvent({ type: 'commands', commands: [{ name: 'plan' }] });
    expect(frames).toHaveLength(1);
    expect(frames[0]!.frame).toMatchObject({ type: 'agent_commands', task_id: taskId, session_id: 's1' });
  });

  it('does not publish a commands frame without a taskId', () => {
    const { broker, frames } = fakeBroker();
    const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' });
    em.onEvent({ type: 'commands', commands: [{ name: 'plan' }] });
    expect(frames).toHaveLength(0);
  });
});

describe('makeFrameEmitter — no broker (one-shot accumulation)', () => {
  it('accumulates output/reasoning/snapshots but publishes nothing', () => {
    const em = makeFrameEmitter({ sessionId: 's1', chatId: 'ch1', assistantId: 'm1' });
    em.onEvent({ type: 'text', text: 'abc' });
    em.onEvent({ type: 'reasoning', text: 'r' });
    em.onEvent({ type: 'tool_call', toolCall: toolSnap });
    expect(em.output).toBe('abc');
    expect(em.reasoningText).toBe('r');
    expect(em.snapshots).toHaveLength(1);
  });
});

describe('makeFrameEmitter — dcp stripping (opencode path contract)', () => {
  it('strips a split dcp tag across deltas and flushes the tail on finalize', () => {
    const { broker, frames } = fakeBroker();
    const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1', dcp: makeDcpStreamStripper() });

    for (const chunk of ['Answer.', '<dcp', '-message', '-id>m1</dcp', '-message-id>', ' tail']) {
      em.onEvent({ type: 'text', text: chunk });
    }
    em.finalize();

    expect(em.output).toBe('Answer. tail');
    const published = frames.filter((f) => f.frame.type === 'delta').map((f) => f.frame.content).join('');
    expect(published).toBe('Answer. tail');
  });

  it('finalize is a no-op without a dcp stripper', () => {
    const { broker, frames } = fakeBroker();
    const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' });
    em.onEvent({ type: 'text', text: 'raw <dcp-message-id>m</dcp-message-id>' });
    em.finalize();
    // No stripping without a stripper — verbatim text (prior ACP-path behavior).
    expect(em.output).toBe('raw <dcp-message-id>m</dcp-message-id>');
    expect(frames).toHaveLength(1);
  });
});
