import { describe, expect, it } from 'bun:test';
import { isKnownServeEvent, mapServeEvent } from './serve-events';

// Fixtures are VERBATIM captures from opencode 1.18.16 — one real turn that ran `echo hello-from-tool`
// through the bash tool and then answered in prose. Ids and timestamps are as they arrived.
//
// They are real for the same reason `runner.test.ts`'s are: this mapping's only job is to match a shape
// nobody documents, so a hand-written fixture would test my imagination rather than the binary. If one
// of these fails after an upgrade, re-capture before editing the expectation.

const SESSION = 'ses_01346bde5ffeB4hWdcQiq1BQUG';
const ASSISTANT = 'msg_fecb96b3e0010o8smEyjxT2noD';
const CALL = 'toolu_01TyLVFSSXus2eoyANdjieWW';

describe('mapServeEvent — text', () => {
  it('turns a text delta into a delta, because that is what makes output appear as it is typed', () => {
    const out = mapServeEvent({
      type: 'session.next.text.delta',
      data: { timestamp: 1, sessionID: SESSION, assistantMessageID: ASSISTANT, textID: 'text-0', delta: 'It' },
    } as never);

    expect(out?.events).toEqual([{ type: 'delta', text: 'It' }]);
    expect(out?.sessionId).toBe(SESSION);
  });

  it('turns the ended text into the committed block', () => {
    const out = mapServeEvent({
      type: 'session.next.text.ended',
      data: { sessionID: SESSION, textID: 'text-0', text: 'It printed **hello-from-tool**.' },
    } as never);

    expect(out?.events).toEqual([{ type: 'text', text: 'It printed **hello-from-tool**.' }]);
  });

  it('says nothing for text.started, which carries no text at all', () => {
    // Verified against the capture: `text.started` has `{textID}` and nothing else. Emitting an empty
    // text here would put a blank assistant bubble on screen before a single token arrived.
    const out = mapServeEvent({
      type: 'session.next.text.started',
      data: { sessionID: SESSION, textID: 'text-0' },
    } as never);
    expect(out?.events).toEqual([]);
  });

  it('drops an empty delta rather than emitting nothing-shaped events', () => {
    expect(mapServeEvent({ type: 'session.next.text.delta', data: { delta: '' } } as never)?.events).toEqual([]);
  });
});

describe('mapServeEvent — tools', () => {
  it('starts the tool row on tool.called, where the input is finally a real object', () => {
    const out = mapServeEvent({
      type: 'session.next.tool.called',
      data: {
        sessionID: SESSION,
        assistantMessageID: ASSISTANT,
        callID: CALL,
        tool: 'bash',
        input: { command: 'echo hello-from-tool' },
      },
    } as never);

    expect(out?.events).toEqual([
      { type: 'tool:start', toolCallId: CALL, toolName: 'bash', toolInput: { command: 'echo hello-from-tool' } },
    ]);
  });

  it('stays silent through the input stream, which is half-parsed JSON', () => {
    // This is the reason `tool:start` waits for `tool.called`. The real delta below is a fragment of a
    // JSON object; rendering a tool row with `{"comman` as its arguments would be worse than waiting.
    const started = mapServeEvent({
      type: 'session.next.tool.input.started',
      data: { sessionID: SESSION, callID: CALL, name: 'bash' },
    } as never);
    const delta = mapServeEvent({
      type: 'session.next.tool.input.delta',
      data: { sessionID: SESSION, callID: CALL, delta: '{"comman' },
    } as never);

    expect(started?.events).toEqual([]);
    expect(delta?.events).toEqual([]);
  });

  it('flattens the success content blocks into one output string', () => {
    const out = mapServeEvent({
      type: 'session.next.tool.success',
      data: {
        sessionID: SESSION,
        callID: CALL,
        structured: { exit: 0, truncated: false },
        content: [{ type: 'text', text: 'hello-from-tool' }],
      },
    } as never);

    expect(out?.events).toEqual([{ type: 'tool:result', toolCallId: CALL, output: 'hello-from-tool', isError: false }]);
  });

  it('reports a failed tool with its error as the output', () => {
    const out = mapServeEvent({
      type: 'session.next.tool.failed',
      data: { sessionID: SESSION, callID: CALL, error: { message: 'command not found' } },
    } as never);

    expect(out?.events).toEqual([
      { type: 'tool:result', toolCallId: CALL, output: 'command not found', isError: true },
    ]);
  });

  it('ignores a tool event with no callID, which cannot be correlated', () => {
    expect(mapServeEvent({ type: 'session.next.tool.called', data: { tool: 'bash' } } as never)?.events).toEqual([]);
    expect(mapServeEvent({ type: 'session.next.tool.success', data: {} } as never)?.events).toEqual([]);
  });
});

describe('mapServeEvent — accounting', () => {
  it('reports per-step tokens and cost for the caller to accumulate', () => {
    const out = mapServeEvent({
      type: 'session.next.step.ended',
      data: {
        sessionID: SESSION,
        finish: 'tool-calls',
        cost: 0.0042,
        tokens: { input: 3, output: 57, reasoning: 0, cache: { read: 3850, write: 0 } },
      },
    } as never);

    expect(out?.costDelta).toEqual({ inputTokens: 3, outputTokens: 57, totalUSD: 0.0042 });
  });

  it('emits nothing terminal for a step that merely ended in tool calls', () => {
    // `finish: "tool-calls"` is a step boundary MID-turn — the model stopped to run a tool and will
    // continue. Treating it as the end of the turn would cut every tool-using conversation in half.
    const out = mapServeEvent({
      type: 'session.next.step.ended',
      data: { sessionID: SESSION, finish: 'tool-calls', cost: 0, tokens: { input: 3, output: 57 } },
    } as never);

    expect(out?.events).toEqual([]);
  });

  it('treats missing tokens and cost as zero rather than NaN', () => {
    const out = mapServeEvent({ type: 'session.next.step.ended', data: { sessionID: SESSION } } as never);
    expect(out?.costDelta).toEqual({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
  });

  it('surfaces a failed step as an error carrying the provider message', () => {
    // The real one, from a probe where the provider was down.
    const out = mapServeEvent({
      type: 'session.next.step.failed',
      data: { sessionID: SESSION, error: { type: 'unknown', message: 'Provider request failed with HTTP 503' } },
    } as never);

    expect(out?.events).toEqual([{ type: 'error', message: 'Provider request failed with HTTP 503' }]);
  });
});

describe('mapServeEvent — envelope', () => {
  it('carries the durable cursor when reading the durable stream, and not otherwise', () => {
    // The per-session stream stamps `durable.seq`; the global live stream does not. The caller needs
    // that difference to know what it may commit and replay.
    const durable = mapServeEvent({
      type: 'session.next.text.ended',
      durable: { seq: 12 },
      data: { sessionID: SESSION, text: 'hi' },
    } as never);
    const live = mapServeEvent({ type: 'session.next.text.ended', data: { sessionID: SESSION, text: 'hi' } } as never);

    expect(durable?.seq).toBe(12);
    expect(live?.seq).toBeUndefined();
  });

  it('always reports the session id, because the live stream is global', () => {
    // Not a detail: `/api/event` carries EVERY session's events, so a consumer that forgets to filter
    // splices one conversation into another.
    for (const type of ['session.next.text.delta', 'session.next.tool.called', 'session.next.step.ended']) {
      expect(mapServeEvent({ type, data: { sessionID: SESSION, delta: 'x', callID: CALL } } as never)?.sessionId).toBe(
        SESSION,
      );
    }
  });

  it('returns null for something that is not an event at all', () => {
    expect(mapServeEvent(null)).toBeNull();
    expect(mapServeEvent(undefined)).toBeNull();
    expect(mapServeEvent({} as never)).toBeNull();
  });

  it('is quiet about an unknown future type, but does not claim to know it', () => {
    // Forward compatibility with a silent tell: an unrecognised type produces no events, and
    // `isKnownServeEvent` is how a caller notices a release added something worth mapping.
    expect(mapServeEvent({ type: 'session.next.something_new_in_1_19', data: {} } as never)?.events).toEqual([]);
    expect(isKnownServeEvent('session.next.something_new_in_1_19')).toBe(false);
    expect(isKnownServeEvent('session.next.text.delta')).toBe(true);
    expect(isKnownServeEvent(undefined)).toBe(false);
  });
});
