/**
 * Claude Code NDJSON Stream Parser
 *
 * Parses the stream-json output format from Claude Code CLI into ChatEvents.
 * Handles: stream_event (deltas), assistant (text/tool_use), user (tool_result),
 * system init, and result messages.
 */

import type { ChatEvent, MessageCost } from '../../api/chat/types';

type SessionCallback = (sessionId: string) => void;

type StreamParserCallbacks = {
  onEvent: (event: ChatEvent) => void;
  onSessionId: SessionCallback;
};

type ParseState = {
  /**
   * Accumulated deltas, keyed by the `parent_tool_use_id` that produced them ('' for the agent you are
   * talking to). One shared buffer was wrong as soon as a subagent ran: its deltas appended to whatever
   * the main agent had said so far, and the next flush emitted the two of them concatenated as a single
   * message from the main agent.
   */
  textBuffers: Map<string, string>;
  gotResult: boolean;
};

/** '' when a message is the main agent's own. Keys the buffer map and stamps every event we emit. */
function parentOf(msg: Record<string, unknown>): string {
  const id = msg.parent_tool_use_id;
  return typeof id === 'string' ? id : '';
}

/** Only set the field when there IS a parent, so main-agent events stay byte-identical on the wire. */
const parented = (parent: string) => (parent ? { parentToolUseId: parent } : {});

function flushTextBuffer(state: ParseState, parent: string, onEvent: (event: ChatEvent) => void): void {
  const buffered = state.textBuffers.get(parent);
  if (buffered) {
    onEvent({ type: 'text', text: buffered, ...parented(parent) });
    state.textBuffers.delete(parent);
  }
}

function handleStreamEvent(msg: Record<string, unknown>, state: ParseState, onEvent: (event: ChatEvent) => void): void {
  const event = msg.event as Record<string, unknown> | undefined;
  if (event?.type === 'content_block_delta') {
    const delta = event.delta as Record<string, unknown> | undefined;
    if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
      const parent = parentOf(msg);
      state.textBuffers.set(parent, (state.textBuffers.get(parent) ?? '') + delta.text);
      onEvent({ type: 'delta', text: delta.text, ...parented(parent) });
    }
  }
}

function handleAssistant(msg: Record<string, unknown>, state: ParseState, onEvent: (event: ChatEvent) => void): void {
  const message = msg.message as Record<string, unknown> | undefined;
  const content = message?.content as Array<Record<string, unknown>> | undefined;
  if (!Array.isArray(content)) return;

  const parent = parentOf(msg);

  for (const block of content) {
    if (block.type === 'text' && typeof block.text === 'string') {
      onEvent({ type: 'text', text: block.text, ...parented(parent) });
      state.textBuffers.delete(parent);
    } else if (block.type === 'tool_use') {
      flushTextBuffer(state, parent, onEvent);
      onEvent({
        type: 'tool:start',
        toolCallId: (block.id as string) ?? '',
        toolName: (block.name as string) ?? 'unknown',
        toolInput: (block.input as Record<string, unknown>) ?? {},
        ...parented(parent),
      });
    }
  }
}

function handleUser(msg: Record<string, unknown>, onEvent: (event: ChatEvent) => void): void {
  const message = msg.message as Record<string, unknown> | undefined;
  const content = message?.content as Array<Record<string, unknown>> | undefined;
  if (!Array.isArray(content)) return;

  const parent = parentOf(msg);

  for (const block of content) {
    if (block.type === 'tool_result') {
      let output = '';
      if (typeof block.content === 'string') {
        output = block.content;
      } else if (Array.isArray(block.content)) {
        output = (block.content as Array<Record<string, unknown>>)
          .filter((c) => c.type === 'text')
          .map((c) => c.text as string)
          .join('\n');
      }
      onEvent({
        type: 'tool:result',
        toolCallId: (block.tool_use_id as string) ?? '',
        output,
        isError: (block.is_error as boolean) ?? false,
        ...parented(parent),
      });
    }
  }
}

function handleResult(msg: Record<string, unknown>, state: ParseState, callbacks: StreamParserCallbacks): void {
  state.gotResult = true;

  const isError = (msg.is_error as boolean) ?? false;
  const resultText = (msg.result as string) ?? '';

  // A `result` ends the turn, so every buffer is stale — including a subagent's, if it died mid-sentence.
  function flushAll(): void {
    for (const parent of [...state.textBuffers.keys()]) flushTextBuffer(state, parent, callbacks.onEvent);
  }

  if (isError) {
    flushAll();
    callbacks.onEvent({ type: 'error', message: resultText || 'Claude Code returned an error' });
    return;
  }

  flushAll();

  const usage = msg.usage as Record<string, number> | undefined;
  const cost: MessageCost = {
    inputTokens: usage?.input_tokens ?? 0,
    outputTokens: usage?.output_tokens ?? 0,
    totalUSD: (msg.total_cost_usd as number) ?? 0,
  };

  const sessionId = msg.session_id as string | undefined;
  if (sessionId) {
    callbacks.onSessionId(sessionId);
  }

  // Carried on the event as well as into sidecar-local state: the callback only feeds `--resume` here,
  // so without this the platform never learns which transcript file a session it started ended up in.
  callbacks.onEvent({ type: 'result', cost, claudeSessionId: sessionId });
}

function handleSystem(msg: Record<string, unknown>, callbacks: StreamParserCallbacks): void {
  const subtype = msg.subtype as string | undefined;
  if (subtype === 'init') {
    const sessionId = msg.session_id as string | undefined;
    if (sessionId) {
      callbacks.onSessionId(sessionId);
      // Also out to the browser, and at the *start* of the turn. The same id used to travel only on
      // `result`, so a chat had no address until its first turn had finished — refresh before that and
      // there was nothing to reconnect by, which is exactly when a long turn is worth reconnecting to.
      callbacks.onEvent({ type: 'session', claudeSessionId: sessionId });
    }
  } else if (subtype === 'task_started') {
    callbacks.onEvent({
      type: 'task:started',
      taskId: (msg.task_id as string) ?? '',
      description: (msg.description as string) ?? '',
      taskType: msg.task_type as string | undefined,
    });
  } else if (subtype === 'task_notification') {
    callbacks.onEvent({
      type: 'task:notification',
      taskId: (msg.task_id as string) ?? '',
      status: (msg.status as 'completed' | 'failed' | 'stopped') ?? 'completed',
      summary: (msg.summary as string) ?? '',
    });
  } else if (subtype === 'compact_boundary') {
    // The harness rewrote its own context. This lands *after* the work — the matching start comes from
    // the `PreCompact` hook — and is the only place the size of what was dropped is reported.
    const meta = (msg.compact_metadata ?? {}) as Record<string, unknown>;
    callbacks.onEvent({
      type: 'compact:done',
      trigger: meta.trigger === 'manual' ? 'manual' : 'auto',
      preTokens: typeof meta.pre_tokens === 'number' ? meta.pre_tokens : 0,
    });
  }
}

/**
 * Map a single already-parsed message object (a Claude Code stream-json message, or the equivalent
 * Agent SDK message — same shapes) to ChatEvents. Returns false if the message type was ignored.
 */
export function processMessage(
  msg: Record<string, unknown>,
  state: ParseState,
  callbacks: StreamParserCallbacks,
): boolean {
  const type = msg.type as string;

  switch (type) {
    case 'stream_event':
      handleStreamEvent(msg, state, callbacks.onEvent);
      break;
    case 'assistant':
      handleAssistant(msg, state, callbacks.onEvent);
      break;
    case 'user':
      handleUser(msg, callbacks.onEvent);
      break;
    case 'system':
      handleSystem(msg, callbacks);
      break;
    case 'result':
      handleResult(msg, state, callbacks);
      break;
    default:
      return false;
  }

  return true;
}

/**
 * Process a single NDJSON line from Claude Code's stream output.
 * Returns false if the line was skipped (empty or malformed), true otherwise.
 */
export function processLine(line: string, state: ParseState, callbacks: StreamParserCallbacks): boolean {
  if (!line.trim()) return false;

  let msg: Record<string, unknown>;
  try {
    msg = JSON.parse(line) as Record<string, unknown>;
  } catch {
    return false;
  }

  return processMessage(msg, state, callbacks);
}

/**
 * Create a fresh parse state for a new stream.
 */
export function createParseState(): ParseState {
  return { textBuffers: new Map(), gotResult: false };
}

/**
 * Read an NDJSON stream and process each line.
 * Returns the final parse state.
 */
export async function parseStream(
  stdout: ReadableStream<Uint8Array>,
  callbacks: StreamParserCallbacks,
): Promise<ParseState> {
  const reader = stdout.getReader();
  const decoder = new TextDecoder();
  const state = createParseState();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const chunk = decoder.decode(value, { stream: true });
    buffer += chunk;
    const lines = buffer.split('\n');
    buffer = lines.pop()!;
    for (const line of lines) {
      processLine(line, state, callbacks);
    }
  }

  if (buffer.trim()) {
    processLine(buffer, state, callbacks);
  }

  return state;
}

export type { ParseState, StreamParserCallbacks };
