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

// `session.next.*` → ChatEvent. Phase A of the serve migration (docs/opencode-serve-migration-plan.md).
//
// NOTHING ROUTES THROUGH THIS YET. Turns still run as `opencode run` subprocesses via runner.ts; this is
// the mapping half of the serve path, written and pinned first so the switch-over is not also the moment
// the parsing is discovered to be wrong.
//
// ── The two streams, which is the part worth understanding ──
//
// The serve publishes the same turn twice, and the split maps exactly onto what officer already does for
// Claude:
//
//   • `GET /api/session/{id}/event?after=<seq>`  — DURABLE, per session, replayable. Every event carries
//     `durable.seq`. Carries whole values (`text.ended` with the full text) and NO deltas. This is the
//     transcript: what belongs in `chat_session_events`, and what a reconnecting browser replays.
//
//   • `GET /api/event`                            — LIVE, GLOBAL, ephemeral. Carries the deltas
//     (`text.delta`, `tool.input.delta`) and no cursor. This is what makes text appear as it is typed.
//
// Measured, not inferred: the same turn produced 13 events on the durable stream and 21 on the live one,
// the difference being 3 `text.delta` and 5 `tool.input.delta`. Reading only the durable stream — which
// is what I did first — makes it look like the serve cannot stream at all, and would have quietly killed
// the main reason for migrating.
//
// The live stream being GLOBAL is the real cost of this design: it carries every session's events, so a
// consumer must filter on `sessionID` and cannot assume it owns the socket.
//
// ── Fixture provenance ──
//
// Every shape here was captured from opencode 1.18.16 running a real turn that used the bash tool and
// then answered in prose. See `serve-events.test.ts`, whose fixtures are verbatim captures.

/** The envelope every `session.next.*` event arrives in. `durable` is present only on the durable stream. */
export type ServeEvent = {
  type?: string;
  durable?: { seq?: number };
  data?: {
    sessionID?: string;
    messageID?: string;
    assistantMessageID?: string;
    callID?: string;
    textID?: string;
    name?: string;
    tool?: string;
    input?: Record<string, unknown>;
    delta?: string;
    text?: string;
    content?: { type?: string; text?: string }[];
    structured?: Record<string, unknown>;
    error?: { message?: string; type?: string };
    finish?: string;
    cost?: number;
    tokens?: { input?: number; output?: number };
    model?: { id?: string; providerID?: string };
  };
};

export type MappedServeEvent = {
  /** The session this belongs to. The live stream is global, so a consumer MUST filter on it. */
  sessionId?: string;
  /** The durable cursor, when this came from the durable stream. Absent on live events. */
  seq?: number;
  events: ChatEvent[];
  costDelta: { inputTokens: number; outputTokens: number; totalUSD: number };
  /**
   * Present only on `step.ended`, and the answer to "is the turn over".
   *
   * A subprocess turn ends by exiting; a serve turn has no such moment, so this is the only signal.
   * `"tool-calls"` means the model paused to run a tool and WILL continue — treating it as terminal cuts
   * every tool-using conversation in half. `"stop"` is the real end. Anything else (e.g. `"length"`) is
   * also an ending, so the caller should test for "not tool-calls" rather than for "stop".
   */
  stepFinish?: string;
};

const NO_COST = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };

/** `tool.success` returns content blocks; officer's `tool:result` wants one string. */
function textOfContent(content: { type?: string; text?: string }[] | undefined): string {
  if (!Array.isArray(content)) return '';
  return content
    .map((c) => (typeof c?.text === 'string' ? c.text : ''))
    .filter(Boolean)
    .join('\n');
}

/**
 * One serve event in, whatever officer should do about it out.
 *
 * Pure by construction, exactly like `mapRunLine`: no accumulation, no memory, no I/O. The caller owns
 * anything that spans events — summing cost across steps, and deciding whether it is reading the live or
 * the durable stream.
 *
 * Returns `null` for an event that is not ours to interpret (another session's, or an envelope with no
 * type). Everything recognised but deliberately ignored returns an empty `events` array instead, so
 * "we saw it and had nothing to say" stays distinguishable from "we did not understand it".
 */
export function mapServeEvent(evt: ServeEvent | null | undefined): MappedServeEvent | null {
  if (!evt || typeof evt.type !== 'string') return null;

  const d = evt.data ?? {};
  const base = { sessionId: d.sessionID, seq: evt.durable?.seq };
  const nothing = (): MappedServeEvent => ({ ...base, events: [], costDelta: NO_COST });

  switch (evt.type) {
    // ── Text ──
    case 'session.next.text.delta': {
      // Live stream only. `delta` is the increment, never the whole value.
      const text = d.delta;
      if (typeof text !== 'string' || text.length === 0) return nothing();
      return { ...base, events: [{ type: 'delta', text }], costDelta: NO_COST };
    }
    case 'session.next.text.ended': {
      // The committed block. Appears on BOTH streams, which is intended: officer's client treats `text`
      // as the value that supersedes whatever the deltas built up, the same as the Claude path.
      const text = d.text;
      if (typeof text !== 'string' || text.length === 0) return nothing();
      return { ...base, events: [{ type: 'text', text }], costDelta: NO_COST };
    }

    // ── Tools ──
    //
    // `tool:start` is emitted on `tool.called`, NOT on `tool.input.started`, because only `tool.called`
    // carries the resolved `input` object. `tool.input.started` knows the name but the arguments are
    // still being streamed a few characters at a time (`tool.input.delta`: `{"comman`), and a tool row
    // rendered with half-parsed JSON as its arguments is worse than one that appears a moment later.
    case 'session.next.tool.called': {
      const toolCallId = d.callID;
      if (!toolCallId) return nothing();
      return {
        ...base,
        events: [
          {
            type: 'tool:start',
            toolCallId,
            toolName: d.tool ?? d.name ?? 'tool',
            toolInput: (d.input as Record<string, unknown>) ?? {},
          },
        ],
        costDelta: NO_COST,
      };
    }
    case 'session.next.tool.success': {
      const toolCallId = d.callID;
      if (!toolCallId) return nothing();
      return {
        ...base,
        events: [{ type: 'tool:result', toolCallId, output: textOfContent(d.content), isError: false }],
        costDelta: NO_COST,
      };
    }
    case 'session.next.tool.failed': {
      const toolCallId = d.callID;
      if (!toolCallId) return nothing();
      // The error replaces the output rather than sitting beside it — same call the run path makes.
      const message = d.error?.message ?? textOfContent(d.content) ?? '';
      return {
        ...base,
        events: [{ type: 'tool:result', toolCallId, output: String(message), isError: true }],
        costDelta: NO_COST,
      };
    }

    // ── Step accounting ──
    case 'session.next.step.ended': {
      // Per-step tokens and cost, to be summed by the caller. `finish: "tool-calls"` is a step boundary
      // mid-turn, not the end of the turn — the turn has ended when the prompt is fully answered, which
      // is a caller-level question, so nothing terminal is emitted here.
      const t = d.tokens;
      return {
        ...base,
        events: [],
        stepFinish: typeof d.finish === 'string' ? d.finish : undefined,
        costDelta: {
          inputTokens: t?.input ?? 0,
          outputTokens: t?.output ?? 0,
          totalUSD: typeof d.cost === 'number' ? d.cost : 0,
        },
      };
    }
    case 'session.next.step.failed': {
      const message = d.error?.message ?? 'OpenCode step failed';
      return { ...base, events: [{ type: 'error', message }], costDelta: NO_COST };
    }

    // ── Seen and deliberately silent ──
    //
    // Named rather than swept into `default` so that a genuinely NEW event type still lands in the
    // unknown bucket, where it can be noticed.
    case 'session.next.prompt.admitted':
    case 'session.next.prompted':
    case 'session.next.step.started':
    case 'session.next.text.started':
    case 'session.next.tool.input.started':
    case 'session.next.tool.input.delta':
    case 'session.next.tool.input.ended':
    case 'session.next.tool.progress':
    case 'session.next.model.switched':
    case 'session.next.agent.switched':
    case 'server.connected':
      return nothing();

    default:
      return nothing();
  }
}

/** Whether a type is one this mapper knows about — for logging what a new opencode release added. */
export const isKnownServeEvent = (type: string | undefined): boolean =>
  typeof type === 'string' && (type === 'server.connected' || KNOWN.has(type));

const KNOWN = new Set([
  'session.next.text.delta',
  'session.next.text.ended',
  'session.next.text.started',
  'session.next.tool.called',
  'session.next.tool.success',
  'session.next.tool.failed',
  'session.next.tool.input.started',
  'session.next.tool.input.delta',
  'session.next.tool.input.ended',
  'session.next.tool.progress',
  'session.next.step.started',
  'session.next.step.ended',
  'session.next.step.failed',
  'session.next.prompt.admitted',
  'session.next.prompted',
  'session.next.model.switched',
  'session.next.agent.switched',
]);
