import { describe, it, expect } from 'vitest';
import type { Event, Part } from '@opencode-ai/sdk/v2/client';
import {
  stripDcpTags,
  eventSessionId,
  resolvePartDedupeKey,
  mapToolStatus,
  toolPartToSnapshot,
  toolCalledSnapshot,
  toolSuccessSnapshot,
  toolFailedSnapshot,
  classifyPartDelta,
  classifyUpdatedPart,
  errToString,
  errMsg,
  type DedupState,
} from '../opencode-event-map.js';

/**
 * Pure opencode Event → AgentEvent translation + dedup gate (v2.7 audit reshape).
 * Mirrors the original `dispatchEvent` / `handleUpdatedPart` arms verbatim — no
 * I/O, so it's unit-testable. The slimmed backend keeps the routing + side effects.
 */

function freshDedup(): DedupState {
  return { streamedPartKeys: new Set(), partTypeById: new Map() };
}

describe('stripDcpTags', () => {
  it('removes a complete dcp tag', () => {
    expect(stripDcpTags('hi <dcp-message-id>m1</dcp-message-id> there')).toBe('hi  there');
  });
  it('leaves untagged text untouched', () => {
    expect(stripDcpTags('plain text <div>')).toBe('plain text <div>');
  });
});

describe('eventSessionId', () => {
  it('reads properties.sessionID for a normal event', () => {
    const ev = { type: 'session.idle', properties: { sessionID: 's1' } } as unknown as Event;
    expect(eventSessionId(ev)).toBe('s1');
  });
  it('reads properties.part.sessionID for message.part.updated', () => {
    const ev = {
      type: 'message.part.updated',
      properties: { part: { sessionID: 's2' } },
    } as unknown as Event;
    expect(eventSessionId(ev)).toBe('s2');
  });
  it('returns null when there is no session', () => {
    const ev = { type: 'server.connected', properties: {} } as unknown as Event;
    expect(eventSessionId(ev)).toBeNull();
  });
});

describe('resolvePartDedupeKey', () => {
  it('prefers the part id', () => {
    expect(resolvePartDedupeKey({ id: 'p1', messageID: 'm1' }, 'text')).toBe('text:p1');
  });
  it('falls back to the message id', () => {
    expect(resolvePartDedupeKey({ id: '  ', messageID: 'm1' }, 'reasoning')).toBe('reasoning:message:m1');
  });
  it('returns null when neither is present', () => {
    expect(resolvePartDedupeKey({ id: '', messageID: '' }, 'text')).toBeNull();
  });
});

describe('mapToolStatus', () => {
  it('maps the opencode tool states to ACP statuses', () => {
    expect(mapToolStatus('pending')).toBe('pending');
    expect(mapToolStatus('running')).toBe('in_progress');
    expect(mapToolStatus('completed')).toBe('completed');
    expect(mapToolStatus('error')).toBe('failed');
    expect(mapToolStatus(undefined)).toBeNull();
  });
});

describe('session.next.tool.* snapshot builders', () => {
  it('toolCalledSnapshot → in_progress with tool title + raw input', () => {
    expect(toolCalledSnapshot({ callID: 'c1', tool: 'read_file', input: { path: 'a.ts' } })).toEqual({
      toolCallId: 'c1',
      title: 'read_file',
      kind: null,
      status: 'in_progress',
      rawInput: { path: 'a.ts' },
      rawOutput: undefined,
    });
  });
  it('toolSuccessSnapshot → completed with joined text content', () => {
    const snap = toolSuccessSnapshot({ callID: 'c1', content: [{ text: 'foo' }, { text: 'bar' }, { other: 1 }] });
    expect(snap.status).toBe('completed');
    expect(snap.title).toBe('c1');
    expect(snap.rawOutput).toBe('foobar');
  });
  it('toolSuccessSnapshot → empty output when content is missing', () => {
    expect(toolSuccessSnapshot({ callID: 'c1' }).rawOutput).toBe('');
  });
  it('toolFailedSnapshot → failed with stringified error', () => {
    const snap = toolFailedSnapshot({ callID: 'c1', error: 'boom' });
    expect(snap.status).toBe('failed');
    expect(snap.title).toBe('c1');
    expect(snap.rawOutput).toBe('boom');
  });
});

describe('toolPartToSnapshot', () => {
  it('extracts input/output/title/status from the tool state', () => {
    const part = {
      type: 'tool',
      callID: 'c1',
      tool: 'grep',
      state: { status: 'completed', input: { q: 'x' }, output: 'result', title: 'Grep run' },
    } as unknown as Parameters<typeof toolPartToSnapshot>[0];
    expect(toolPartToSnapshot(part)).toEqual({
      toolCallId: 'c1',
      title: 'Grep run',
      kind: null,
      status: 'completed',
      rawInput: { q: 'x' },
      rawOutput: 'result',
    });
  });
  it('falls back to the tool name and uses error as output', () => {
    const part = {
      type: 'tool',
      callID: 'c2',
      tool: 'edit',
      state: { status: 'error', error: 'nope' },
    } as unknown as Parameters<typeof toolPartToSnapshot>[0];
    const snap = toolPartToSnapshot(part);
    expect(snap.title).toBe('edit');
    expect(snap.status).toBe('failed');
    expect(snap.rawOutput).toBe('nope');
  });
});

describe('classifyPartDelta (message.part.delta dedup recording)', () => {
  it('records a reasoning key and emits a reasoning event', () => {
    const st = freshDedup();
    const e = classifyPartDelta({ partID: 'p1', field: 'reasoning', delta: 'thinking' }, st);
    expect(e).toEqual({ type: 'reasoning', text: 'thinking' });
    expect(st.streamedPartKeys.has('reasoning:p1')).toBe(true);
  });
  it('records a text key, strips dcp, and emits text', () => {
    const st = freshDedup();
    const e = classifyPartDelta({ partID: 'p2', field: 'text', delta: 'hi <dcp-message-id>m</dcp-message-id>' }, st);
    expect(e).toEqual({ type: 'text', text: 'hi ' });
    expect(st.streamedPartKeys.has('text:p2')).toBe(true);
  });
  it('still records the text key even when the cleaned delta is empty', () => {
    const st = freshDedup();
    const e = classifyPartDelta({ partID: 'p3', field: 'text', delta: '<dcp-message-id>m</dcp-message-id>' }, st);
    expect(e).toBeNull();
    expect(st.streamedPartKeys.has('text:p3')).toBe(true);
  });
  it('uses the recorded part type when the field is absent', () => {
    const st = freshDedup();
    st.partTypeById.set('p4', 'reasoning');
    const e = classifyPartDelta({ partID: 'p4', delta: 'more' }, st);
    expect(e).toEqual({ type: 'reasoning', text: 'more' });
  });
  it('returns null for an unknown field', () => {
    expect(classifyPartDelta({ partID: 'p5', field: 'other', delta: 'x' }, freshDedup())).toBeNull();
  });
});

describe('classifyUpdatedPart (message.part.updated dedup gate)', () => {
  function textPart(over: Partial<Part> = {}): Part {
    return {
      type: 'text',
      id: 'p1',
      messageID: 'm1',
      sessionID: 's1',
      text: 'final text',
      time: { start: 1, end: 2 },
      ...over,
    } as unknown as Part;
  }

  it('drops a terminal part already streamed via deltas', () => {
    const st = freshDedup();
    st.streamedPartKeys.add('text:p1');
    expect(classifyUpdatedPart(textPart(), st)).toBeNull();
    // the key is consumed
    expect(st.streamedPartKeys.has('text:p1')).toBe(false);
  });
  it('emits a finished (ended) text part not seen via deltas', () => {
    const st = freshDedup();
    expect(classifyUpdatedPart(textPart(), st)).toEqual({ type: 'text', text: 'final text' });
    expect(st.partTypeById.get('p1')).toBe('text');
  });
  it('does not emit a part that has not ended yet', () => {
    const st = freshDedup();
    expect(classifyUpdatedPart(textPart({ time: { start: 1 } as never }), st)).toBeNull();
  });
  it('strips dcp tags from the finished text', () => {
    const st = freshDedup();
    const part = textPart({ text: 'a <dcp-message-id>m</dcp-message-id>b' });
    expect(classifyUpdatedPart(part, st)).toEqual({ type: 'text', text: 'a b' });
  });
  it('maps a running tool part to tool_call', () => {
    const st = freshDedup();
    const part = { type: 'tool', callID: 'c1', tool: 'grep', state: { status: 'running' } } as unknown as Part;
    const e = classifyUpdatedPart(part, st);
    expect(e?.type).toBe('tool_call');
  });
  it('maps a completed tool part to tool_update', () => {
    const st = freshDedup();
    const part = { type: 'tool', callID: 'c1', tool: 'grep', state: { status: 'completed', output: 'x' } } as unknown as Part;
    const e = classifyUpdatedPart(part, st);
    expect(e?.type).toBe('tool_update');
  });
});

describe('error formatters', () => {
  it('errMsg unwraps Error.message', () => {
    expect(errMsg(new Error('x'))).toBe('x');
    expect(errMsg('plain')).toBe('plain');
  });
  it('errToString handles null/string/Error/object', () => {
    expect(errToString(null)).toBe('unknown error');
    expect(errToString('s')).toBe('s');
    expect(errToString(new Error('e'))).toBe('e');
    expect(errToString({ a: 1 })).toBe('{"a":1}');
  });
});
