/**
 * A stored conversation, read back into the shape the transcript renders.
 *
 * The store keeps one row per THING that happened — a question, a tool call, a
 * proposed change, an answer — because that is what an append-only ledger has
 * to do to survive a turn that dies half-way through. The transcript renders one
 * block per TURN, with its tools and its approval cards inside it. Folding the
 * rows back into turns is therefore not a formality; it is the difference
 * between reopening a conversation and reopening a log file.
 *
 * The wire shapes below mirror `PiggyConversationDetail` in
 * apps/api/src/services/piggy-conversations.ts. They are restated rather than
 * imported because the browser cannot import from the API package, and every
 * field is optional-tolerant on read for the same reason: a row written by an
 * older build must reopen as a slightly plainer message, never as a blank pane.
 */
import type { PiggyChatContext, PiggyMode, PiggyProposedChange } from '@pig/core';
import type { ApprovalStep, ToolStep, TranscriptMessage } from '@/lib/piggy-chat';

export interface StoredPiggyMessage {
  id: string;
  seq: number;
  role: 'user' | 'assistant' | 'tool';
  content: string;
  reasoning: string | null;
  model: string | null;
  mode: PiggyMode | null;
  inputTokens: number | null;
  outputTokens: number | null;
  costMicroCents: number | null;
  finishReason: string | null;
  tool: {
    callId: string;
    name: string;
    arguments: Record<string, unknown> | null;
    result: Record<string, unknown> | null;
    ok: boolean | null;
  } | null;
  approval: {
    id: string;
    change: PiggyProposedChange;
    decision: 'apply' | 'reject' | null;
    decidedAt: string | null;
  } | null;
  error: string | null;
  createdAt: string;
}

export interface StoredPiggyConversation {
  id: string;
  title: string;
  model: string | null;
  mode: PiggyMode | null;
  context: PiggyChatContext | null;
  createdAt: string;
  updatedAt: string;
  messages: StoredPiggyMessage[];
}

/**
 * A change that was proposed and never answered.
 *
 * It is not offered as pending on reopening, and that is deliberate rather than
 * cautious: the agent holds a proposal for the length of its own turn, so by the
 * time a transcript is read back from the database there is nothing left at the
 * other end for an Apply button to reach. Showing the buttons would collect an
 * error; showing the card settled says what happened.
 */
const UNANSWERED = 'This change was never answered, and the turn that proposed it has ended.';

export function toTranscript(messages: StoredPiggyMessage[]): TranscriptMessage[] {
  const transcript: TranscriptMessage[] = [];
  // The assistant turn currently being assembled. Tool rows and approval rows
  // belong to whichever answer they were streamed alongside, and they arrive
  // BEFORE its text — the answer is written last.
  let open: TranscriptMessage | null = null;

  for (const row of [...messages].sort((a, b) => a.seq - b.seq)) {
    if (row.role === 'user') {
      if (open) transcript.push(open);
      open = null;
      transcript.push({ id: row.id, role: 'user', content: row.content });
      continue;
    }

    if (row.tool) {
      const turn: TranscriptMessage = open ?? newTurn(row.id);
      turn.tools = [...(turn.tools ?? []), toToolStep(row.tool)];
      open = turn;
      continue;
    }

    if (row.approval) {
      const turn: TranscriptMessage = open ?? newTurn(row.id);
      turn.approvals = [...(turn.approvals ?? []), toApprovalStep(row.approval)];
      open = turn;
      continue;
    }

    // A second answer inside one turn cannot happen on the wire, but a repaired
    // or re-run conversation could hold one; starting a fresh block is the only
    // reading that does not silently concatenate two answers into one.
    if (open && open.content) {
      transcript.push(open);
      open = null;
    }
    const turn: TranscriptMessage = open ?? newTurn(row.id);
    turn.id = row.id;
    turn.content = row.content;
    turn.reasoning = row.reasoning ?? undefined;
    turn.model = row.model ?? undefined;
    turn.mode = row.mode ?? undefined;
    turn.inputTokens = row.inputTokens;
    turn.outputTokens = row.outputTokens;
    turn.costMicroCents = row.costMicroCents;
    turn.finishReason = row.finishReason ?? undefined;
    turn.error = row.error ?? undefined;
    transcript.push(turn);
    open = null;
  }

  if (open) transcript.push(open);
  return transcript;
}

function newTurn(id: string): TranscriptMessage {
  return { id, role: 'assistant', content: '', tools: [], approvals: [], pending: false };
}

function toToolStep(tool: NonNullable<StoredPiggyMessage['tool']>): ToolStep {
  return {
    id: tool.callId,
    name: tool.name,
    arguments: tool.arguments ?? {},
    // `ok: null` is a call the store never saw finish. It is drawn as succeeded
    // rather than running: a spinner in a transcript read back from disk would
    // never stop, and the payload beside it is the evidence either way.
    state: tool.ok === false ? 'failed' : 'succeeded',
    result: tool.result ?? undefined,
    /*
     * No clock. `startedAt` is `performance.now()` on the live path, which is
     * milliseconds since this document loaded and means nothing for a call made
     * last Tuesday. Zero with no `durationMs` renders as a step with no timing,
     * which is honest; a computed one would be fiction.
     */
    startedAt: 0,
  };
}

function toApprovalStep(approval: NonNullable<StoredPiggyMessage['approval']>): ApprovalStep {
  if (approval.decision === 'apply') {
    return { change: approval.change, state: 'applied', decision: 'apply' };
  }
  if (approval.decision === 'reject') {
    return { change: approval.change, state: 'rejected', decision: 'reject' };
  }
  return { change: approval.change, state: 'failed', error: UNANSWERED };
}
