import type { Message } from "#cli/core/model";
import type { ThemeState } from "#cli/core/theme";
import type { Style } from "@simon_he/vue-tui";
import type { UiThemeContext } from "../theme";
import type { AskUserInlineState } from "./renderers";
import type { BlockLine, Line, LineSegment } from "./types";
import {
  isOneShotEnabled,
  isPerfEnabled,
  perfLog as perfLogLine,
} from "#cli/core/observability/perf-log";
import { normalizeThemeMode, resolveMessageTypeColors } from "#cli/core/theme";
import { createUiThemeContext, resolveUiThemeForMode } from "../theme";
import {
  renderAskUserInlineLines,
  renderAssistantMessageLines,
  renderUserMessageLines,
} from "./renderers";

export type { AskUserInlineOption, AskUserInlineQuestion, AskUserInlineState } from "./renderers";
export type { BlockLine, Line, LineAction, LineSegment } from "./types";

export type ChatLayoutModel = Readonly<{
  lineCount: number;
  getLines: (start: number, end: number) => Line[];
  getLine: (row: number) => Line | undefined;
  toolLineById: Map<string, number>;
  messageLineById: Map<string, number>;
  lines: Line[];
}>;

const EMPTY_STRING_SET: ReadonlySet<string> = new Set();
const EMPTY_NUMBER_MAP: ReadonlyMap<string, number> = new Map();

export type ChatLayoutModelBuildOpts = Readonly<{
  messages: readonly Message[];
  width: number;
  selectedToolCallId: string | null;
  expandedBashOutputs: ReadonlySet<string>;
  expandedToolOutputLineCounts?: ReadonlyMap<string, number>;
  collapsedToolCallIds: ReadonlySet<string>;
  expandedToolCallGroups?: ReadonlySet<string>;
  unifiedEditDiffToolCallIds?: ReadonlySet<string>;
  expandToolCallsAndThinking?: boolean;
  pendingToolCallIds?: ReadonlySet<string>;
  toolCallBlinkOn?: ToolCallBlinker;
  thinkingHeaderPulse?: Readonly<{
    messageId: string;
    partIndex: number;
    frame: number;
  }> | null;
  streamingMarkdownReveal?: Readonly<{
    messageId: string;
    partIndex: number;
    visibleChars: number;
  }> | null;
  deferredTerminalMarkdownUpgrade?: Readonly<{
    messageId: string;
    partIndex: number;
  }> | null;
  activeApprovalId?: string | null;
  activeApprovalActionIndex?: number;
  activeApprovalToolCallId?: string | null;
  suppressActivePendingApprovalInline?: boolean;
  askUserState?: AskUserInlineState | null;
  ui?: UiThemeContext;
  theme: ThemeState;
  /**
   * Optional "dirty" hint for non-tail updates (e.g. user toggles a collapsed block
   * in an older message). Pair with `dirtySeq` so the builder only checks when needed.
   */
  dirtyMessageId?: string | null;
  dirtySeq?: number;
}>;

type MessageBlock = Readonly<{
  role: Message["role"];
  messageId: string;
  lines: readonly BlockLine[];
}>;

type ToolLineMeta = Readonly<{ callId: string; line: number }>;

type MessageLayoutMeta = Readonly<{
  fp: string;
  lineCount: number;
  toolLines: readonly ToolLineMeta[];
  firstMessageLineOffset: number;
}>;

type ToolCallBlinker = (callId: string, startedAtMs?: number) => boolean;

type ThinkingHeaderPulse = Readonly<{
  messageId: string;
  partIndex: number;
  frame: number;
}>;

type StreamingMarkdownReveal = Readonly<{
  messageId: string;
  partIndex: number;
  visibleChars: number;
}>;

type DeferredTerminalMarkdownUpgrade = Readonly<{
  messageId: string;
  partIndex: number;
}>;

function themeSignature(theme: ThemeState): string {
  return normalizeThemeMode((theme as any)?.mode ?? (theme as any)?.preset);
}

function smallHash(text: string): string {
  let hash = 0;
  const str = String(text ?? "");
  for (let i = 0; i < str.length; i++) {
    const ch = str.charCodeAt(i);
    hash = (hash << 5) - hash + ch;
    hash = hash & hash;
  }
  return hash.toString(36);
}

type ToolCallBlinkMeta = Readonly<{ id: string; startedAtMs: number }>;

type FingerprintCacheEntry = Readonly<{
  base: string;
  toolCalls: readonly ToolCallBlinkMeta[];
}>;

const ASSISTANT_FINGERPRINT_CACHE = new WeakMap<Message, FingerprintCacheEntry>();

function computeToolCallBlinkSig(
  toolCalls: readonly ToolCallBlinkMeta[],
  pendingToolCallIds?: ReadonlySet<string>,
  toolCallBlinkOn?: ToolCallBlinker,
): string {
  if (!pendingToolCallIds || pendingToolCallIds.size === 0 || typeof toolCallBlinkOn !== "function")
    return "";
  let sig = "";
  for (const tc of toolCalls) {
    if (!tc.id || !pendingToolCallIds.has(tc.id)) continue;
    const blinkOn = toolCallBlinkOn(tc.id, tc.startedAtMs);
    sig += `|b:${tc.id}:${blinkOn ? 1 : 0}`;
  }
  return sig;
}

function computeThinkingPulseSig(m: Message, pulse?: ThinkingHeaderPulse | null): string {
  if (!pulse || m.role !== "assistant") return "";
  if (pulse.messageId !== m.id) return "";
  const partIndex = Number.isFinite(pulse.partIndex) ? Math.floor(pulse.partIndex) : -1;
  if (partIndex < 0) return "";
  const frame = Number.isFinite(pulse.frame) ? Math.floor(pulse.frame) : 0;
  return `|th:${partIndex}:${frame % 128}`;
}

function computeStreamingRevealSig(m: Message, reveal?: StreamingMarkdownReveal | null): string {
  if (!reveal || m.role !== "assistant") return "";
  if (reveal.messageId !== m.id) return "";
  const partIndex = Number.isFinite(reveal.partIndex) ? Math.floor(reveal.partIndex) : -1;
  if (partIndex < 0) return "";
  const visibleChars = Number.isFinite(reveal.visibleChars)
    ? Math.max(0, Math.floor(reveal.visibleChars))
    : 0;
  return `|sr:${partIndex}:${visibleChars}`;
}

function computeDeferredTerminalMarkdownSig(
  m: Message,
  deferred?: DeferredTerminalMarkdownUpgrade | null,
): string {
  if (!deferred || m.role !== "assistant") return "";
  if (deferred.messageId !== m.id) return "";
  const partIndex = Number.isFinite(deferred.partIndex) ? Math.floor(deferred.partIndex) : -1;
  if (partIndex < 0) return "";
  return `|dt:${partIndex}`;
}

function fingerprintMessage(
  m: Message,
  latestAssistantMessageId: string | null,
  pendingToolCallIds?: ReadonlySet<string>,
  toolCallBlinkOn?: ToolCallBlinker,
  thinkingHeaderPulse?: ThinkingHeaderPulse | null,
  streamingMarkdownReveal?: StreamingMarkdownReveal | null,
  deferredTerminalMarkdownUpgrade?: DeferredTerminalMarkdownUpgrade | null,
): string {
  const askUserState = (m as any)?.__askUser as AskUserInlineState | undefined;
  if (askUserState) {
    const sig = smallHash(
      JSON.stringify({
        toolCallId: askUserState.toolCallId,
        currentIndex: askUserState.currentIndex,
        questions: askUserState.questions,
        selections: askUserState.selections,
        otherInputs: askUserState.otherInputs,
        optionFocusByQuestion: askUserState.optionFocusByQuestion,
        focusTarget: askUserState.focusTarget,
      }),
    );
    return `ask:${sig}`;
  }
  if (m.role === "user") {
    const content = String(m.content ?? "");
    const focusLen = (m as any)?.focusFiles?.length ?? 0;
    const modelId = String((m as any)?.modelId ?? "");
    const at = Number((m as any)?.createdAtMs ?? 0);
    return `u:${content.length}:${focusLen}:${modelId.length}:${Number.isFinite(at) ? at : 0}`;
  }
  if (m.role === "assistant") {
    const cached = ASSISTANT_FINGERPRINT_CACHE.get(m);
    if (cached) {
      return `${
        cached.base
      }|cf:${m.id === latestAssistantMessageId ? 1 : 0}${computeToolCallBlinkSig(
        cached.toolCalls,
        pendingToolCallIds,
        toolCallBlinkOn,
      )}${computeThinkingPulseSig(m, thinkingHeaderPulse)}${computeStreamingRevealSig(m, streamingMarkdownReveal)}${computeDeferredTerminalMarkdownSig(m, deferredTerminalMarkdownUpgrade)}`;
    }

    const parts = (m.parts ?? []) as any[];
    const modelId = String((m as any)?.modelId ?? "");
    const at = Number((m as any)?.createdAtMs ?? 0);
    let sig = `a:${parts.length}:${modelId.length}:${Number.isFinite(at) ? at : 0}`;
    const toolCalls: ToolCallBlinkMeta[] = [];
    for (const p of parts) {
      const t = String(p?.type ?? "");
      if (t === "status") {
        sig += `|s:${String(p?.text ?? "").length}`;
      } else if (t === "thinking") {
        sig += `|th:${String(p?.title ?? "").length}:${p?.collapsed ? 1 : 0}:${String(p?.content ?? "").length}`;
      } else if (t === "markdown") {
        const md = String(p?.markdown ?? "");
        const mdLen = md.length;
        const rawFullLen = Number(p?.__fullLen);
        const fullLen =
          Number.isFinite(rawFullLen) && rawFullLen >= 0 ? Math.floor(rawFullLen) : mdLen;
        const tail = mdLen > 128 ? md.slice(-128) : md;
        const tailHash = smallHash(tail);
        const blocks = Array.isArray(p?.codeBlocks) ? (p.codeBlocks as any[]) : [];
        if (blocks.length === 0) {
          sig += `|m:${fullLen}:${mdLen}:${tailHash}`;
        } else {
          const blockSig = blocks
            .map((b) => {
              const kind = String(b?.kind ?? "");
              const hash = String(b?.hash ?? "");
              const totalLines = Number(b?.totalLines ?? 0);
              if (kind === "shiki") {
                const langLen = String(b?.language ?? "").length;
                const themeLen = String(b?.theme ?? "").length;
                const lineCount = Array.isArray(b?.lines) ? b.lines.length : 0;
                return `s:${hash}:${totalLines}:${lineCount}:${langLen}:${themeLen}`;
              }
              const reasonLen = String(b?.reason ?? "").length;
              return `k:${hash}:${totalLines}:${reasonLen}`;
            })
            .join(",");
          sig += `|m:${fullLen}:${mdLen}:${tailHash}:cb:${blocks.length}:${blockSig}`;
        }
      } else if (t === "tool_call") {
        const call = p?.call ?? {};
        const argsText = call?.argumentsText;
        const argsTail = typeof argsText === "string" ? argsText : "";
        const argsLen = argsTail.length;
        const callId = String(call?.id ?? "");
        if (callId) {
          const startedAtMs = Number(call?.startedAtMs ?? 0);
          toolCalls.push({
            toolCallId: callId,
            startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : 0,
          });
        }
        const rawArgsFullLen = Number((call as any)?.__argsTextFullLen);
        const argsFullLen =
          Number.isFinite(rawArgsFullLen) && rawArgsFullLen >= 0
            ? Math.floor(rawArgsFullLen)
            : argsLen;
        const argsTailHash = smallHash(argsLen > 256 ? argsTail.slice(-256) : argsTail);
        const argsObj =
          call?.arguments && typeof call.arguments === "object" && !Array.isArray(call.arguments)
            ? (call.arguments as Record<string, unknown>)
            : null;
        const argsSig = (() => {
          if (!argsObj) return "";
          const a: any = argsObj as any;
          const keys = Object.keys(a);
          const filePathLen =
            typeof a.file_path === "string"
              ? String(a.file_path).length
              : typeof a.filePath === "string"
                ? String(a.filePath).length
                : typeof a.path === "string"
                  ? String(a.path).length
                  : 0;
          const patternLen = typeof a.pattern === "string" ? String(a.pattern).length : 0;
          const commandLen = typeof a.command === "string" ? String(a.command).length : 0;
          const descLen = typeof a.description === "string" ? String(a.description).length : 0;
          const oldLen = typeof a.old_string === "string" ? String(a.old_string).length : 0;
          const newLen = typeof a.new_string === "string" ? String(a.new_string).length : 0;
          const questionsLen = Array.isArray(a.questions) ? a.questions.length : 0;
          const todosLen = Array.isArray(a.todos) ? a.todos.length : 0;
          return `:ak:${keys.length}:${filePathLen}:${patternLen}:${commandLen}:${descLen}:${oldLen}:${newLen}:${questionsLen}:${todosLen}`;
        })();
        const diffPreview = call?.diffPreview;
        const diffSig =
          diffPreview?.kind === "shiki"
            ? `:dp:${String(diffPreview.language ?? "").length}:${String(diffPreview.theme ?? "").length}:${String(diffPreview.oldString ?? "").length}:${String(diffPreview.newString ?? "").length}:${Array.isArray(diffPreview.lines) ? diffPreview.lines.length : 0}`
            : "";

        const readPreview = call?.readPreview;
        const readSig =
          readPreview?.kind === "shiki"
            ? `:rp:${String(readPreview.mode ?? "")}:${String(readPreview.language ?? "").length}:${String(readPreview.theme ?? "").length}:${String(readPreview.hash ?? "")}:${Number(readPreview.totalLines ?? 0)}:${Array.isArray(readPreview.lines) ? readPreview.lines.length : 0}`
            : readPreview?.kind === "skipped"
              ? `:rp_skip:${String(readPreview.mode ?? "")}:${String(readPreview.hash ?? "")}:${Number(readPreview.totalLines ?? 0)}:${String(readPreview.reason ?? "").length}`
              : "";

        sig += `|tc:${String(call?.id ?? "")}:${argsLen}:${argsFullLen}:${argsTailHash}${argsSig}${diffSig}${readSig}`;
      } else if (t === "tool_output") {
        const out = String(p?.output ?? "");
        const outLen = out.length;
        const rawOutFullLen = Number((p as any)?.__fullLen);
        const outFullLen =
          Number.isFinite(rawOutFullLen) && rawOutFullLen >= 0 ? Math.floor(rawOutFullLen) : outLen;
        const outTailHash = smallHash(outLen > 256 ? out.slice(-256) : out);
        sig += `|to:${String(p?.toolCallId ?? "")}:${String(p?.toolName ?? "").length}:${outLen}:${outFullLen}:${outTailHash}`;
      } else if (t === "tool_result") {
        const r = p?.result ?? {};
        sig += `|tr:${String(r?.id ?? "")}:${String(r?.status ?? "")}:${String(r?.output ?? "").length}:${String(r?.outputAnsi ?? "").length}`;
      } else if (t === "approve") {
        const r = p?.request ?? {};
        sig += `|ap:${String(r?.id ?? "")}:${String(r?.status ?? "")}`;
      } else if (t === "todo" || t === "plan") {
        const items = (p?.items ?? []) as any[];
        let done = 0;
        for (const it of items) {
          if (it?.done) done++;
        }
        sig += `|${t}:${String(p?.title ?? "").length}:${p?.collapsed ? 1 : 0}:${items.length}:${done}`;
      } else if (t === "llm_meta") {
        const changedFiles = Array.isArray((p as any)?.changedFiles)
          ? ((p as any).changedFiles as unknown[])
              .map((v) => String(v ?? "").trim())
              .filter(Boolean)
          : [];
        const changedFilesHash =
          changedFiles.length > 0 ? smallHash(changedFiles.join("\u0001")) : "";
        const changedFileDetails = Array.isArray((p as any)?.changedFileDetails)
          ? ((p as any).changedFileDetails as unknown[])
              .map((item) => {
                const detail = item as {
                  path?: unknown;
                  added?: unknown;
                  removed?: unknown;
                };
                return `${String(detail?.path ?? "").trim()}:${Number(detail?.added ?? -1)}:${Number(detail?.removed ?? -1)}`;
              })
              .filter(Boolean)
          : [];
        const changedFileDetailsHash =
          changedFileDetails.length > 0 ? smallHash(changedFileDetails.join("\u0001")) : "";
        sig += `|lm:${Math.floor(Number(p?.durationMs ?? 0))}:${String(p?.modelId ?? "").length}:${Math.floor(Number(p?.toolCalls ?? 0))}:${Math.floor(Number(p?.tokensUsed ?? 0))}:${changedFiles.length}:${changedFilesHash}:${changedFileDetails.length}:${changedFileDetailsHash}`;
      } else if (t === "info" && p?._subagent) {
        // Include full text for activity/finished so ticker changes are detected
        const subText = String(p.text ?? "");
        const subSig =
          p._subagent === "header" ? `${subText.length}:${Number(p._frame ?? 0)}` : subText;
        sig += `|info:${String(p._subagent)}:${subSig}`;
      } else {
        sig += `|${t}`;
      }
    }
    const entry: FingerprintCacheEntry = { base: sig, toolCalls };
    ASSISTANT_FINGERPRINT_CACHE.set(m, entry);
    return `${sig}|cf:${m.id === latestAssistantMessageId ? 1 : 0}${computeToolCallBlinkSig(
      toolCalls,
      pendingToolCallIds,
      toolCallBlinkOn,
    )}${computeThinkingPulseSig(m, thinkingHeaderPulse)}${computeStreamingRevealSig(m, streamingMarkdownReveal)}${computeDeferredTerminalMarkdownSig(m, deferredTerminalMarkdownUpgrade)}`;
  }
  const r = (m as any)?.result ?? {};
  return `t:${String(r?.id ?? "")}:${String(r?.status ?? "")}:${String(r?.output ?? "").length}`;
}

function findLatestAssistantMessageId(messages: readonly Message[]): string | null {
  for (let i = messages.length - 1; i >= 0; i--) {
    const message = messages[i];
    if (message?.role === "assistant") return message.id;
  }
  return null;
}

function renderMessageBlock(
  m: Message,
  latestAssistantMessageId: string | null,
  width: number,
  selectedToolCallId: string | null,
  expandedBashOutputs: ReadonlySet<string>,
  expandedToolOutputLineCounts: ReadonlyMap<string, number>,
  collapsedToolCallIds: ReadonlySet<string>,
  expandedToolCallGroups: ReadonlySet<string>,
  unifiedEditDiffToolCallIds: ReadonlySet<string>,
  expandToolCallsAndThinking: boolean,
  pendingToolCallIds: ReadonlySet<string> | undefined,
  activeApprovalId: string | null | undefined,
  activeApprovalActionIndex: number | undefined,
  suppressActivePendingApprovalInline: boolean | undefined,
  toolCallBlinkOn: ToolCallBlinker | undefined,
  thinkingHeaderPulse: ThinkingHeaderPulse | null | undefined,
  streamingMarkdownReveal: StreamingMarkdownReveal | null | undefined,
  deferredTerminalMarkdownUpgrade:
    | Readonly<{ messageId: string; partIndex: number }>
    | null
    | undefined,
  theme: ThemeState,
  ui: UiThemeContext,
): MessageBlock | null {
  const contentIndent = 2;

  const askUserState = (m as any)?.__askUser as AskUserInlineState | undefined;
  if (askUserState) {
    const lines = renderAskUserInlineLines({
      state: askUserState,
      width,
      contentIndent,
      theme,
      ui,
    });
    return {
      role: "assistant",
      messageId: m.id,
      lines,
    };
  }

  if (m.role === "user") {
    const lines = renderUserMessageLines({
      message: m,
      width,
      contentIndent,
      theme,
      ui,
    });
    return {
      role: m.role,
      messageId: m.id,
      lines,
    };
  }
  if (m.role === "tool") {
    return null;
  }

  const lines = renderAssistantMessageLines({
    message: m,
    showChangedFilesCard: m.id === latestAssistantMessageId,
    width,
    contentIndent,
    selectedToolCallId,
    expandedBashOutputs,
    expandedToolOutputLineCounts,
    collapsedToolCallIds,
    expandedToolCallGroups,
    unifiedEditDiffToolCallIds,
    expandToolCallsAndThinking,
    pendingToolCallIds,
    activeApprovalId,
    activeApprovalActionIndex,
    suppressActivePendingApprovalInline,
    toolCallBlinkOn,
    thinkingHeaderPulse,
    streamingMarkdownReveal,
    deferredTerminalMarkdownUpgrade,
    theme,
    ui,
  });
  if (lines.length === 0) return null;
  return {
    role: m.role,
    messageId: m.id,
    lines,
  };
}

function decorateBlock(
  block: MessageBlock,
  theme: ThemeState,
  ui: UiThemeContext,
  isFirstBlock: boolean,
): Line[] {
  const out: Line[] = [];
  const assistantBase = resolveMessageTypeColors(theme, "assistant");
  const userBase = resolveMessageTypeColors(theme, "user");
  const windowBg: Style["bg"] = ui.token("bg.window")?.bg ?? "black";
  const chromeBg: Style["bg"] = ui.token("bg.chrome")?.bg ?? "blackBright";
  const assistantBg: Style["bg"] = assistantBase.bg ?? windowBg;
  const userBg: Style["bg"] = userBase.bg ?? chromeBg;
  const spacerBg: Style["bg"] = assistantBg;

  if (!isFirstBlock) {
    out.push({
      text: "",
      style: { bg: spacerBg },
      segments: [],
      hasBackground: false,
    });
  }

  const isUser = block.role === "user";

  const ensureSegmentsBg = (segments: LineSegment[], bg: Style["bg"]): LineSegment[] => {
    if (segments.length === 0) return segments;
    for (let i = 0; i < segments.length; i++) {
      const seg = segments[i]!;
      if (!seg?.style?.bg) {
        const outSegs: LineSegment[] = Array.from({ length: segments.length });
        for (let j = 0; j < segments.length; j++) {
          const s = segments[j]!;
          const st = s.style;
          outSegs[j] = st?.bg ? s : { ...s, style: st ? { ...st, bg } : { bg } };
        }
        return outSegs;
      }
    }
    return segments;
  };

  if (isUser) {
    out.push({
      text: "",
      style: { bg: userBg },
      segments: [],
      hasBackground: true,
      messageId: block.messageId,
    });
  }

  for (const line of block.lines) {
    if (isUser) {
      const lineStyle = line.style;
      const style = lineStyle?.bg === userBg ? lineStyle : { ...lineStyle, bg: userBg };
      out.push({
        text: line.text,
        style,
        segments: ensureSegmentsBg(line.segments, userBg),
        action: line.action,
        hasBackground: true,
        messageId: block.messageId,
      });
    } else {
      const lineBg = (line.style?.bg ?? assistantBg) as Style["bg"];
      const lineStyle = line.style;
      const style = lineStyle?.bg === lineBg ? lineStyle : { ...lineStyle, bg: lineBg };
      out.push({
        text: line.text,
        style,
        segments: ensureSegmentsBg(line.segments, lineBg),
        action: line.action,
        hasBackground: false,
        messageId: block.messageId,
      });
    }
  }

  if (isUser) {
    out.push({
      text: "",
      style: { bg: userBg },
      segments: [],
      hasBackground: true,
      messageId: block.messageId,
    });
  }

  return out;
}

function extractToolLines(lines: readonly Line[]): ToolLineMeta[] {
  const out: ToolLineMeta[] = [];
  for (let i = 0; i < lines.length; i++) {
    const a = lines[i]?.action;
    if (a?.type === "selectToolCall") out.push({ callId: a.callId, line: i });
    else if (a?.type === "selectToolCallGroup") out.push({ callId: a.groupKey, line: i });
  }
  return out;
}

function firstMessageLineOffset(lines: readonly Line[], messageId: string): number {
  for (let i = 0; i < lines.length; i++) {
    if ((lines[i] as any)?.messageId === messageId) return i;
  }
  return 0;
}

function appendAskUserMessage(
  messages: readonly Message[],
  askUserState?: AskUserInlineState | null,
): Message[] {
  if (!askUserState)
    return Array.isArray(messages) ? (messages as Message[]) : Array.from(messages);
  const toolCallId = String(askUserState.toolCallId ?? "").trim() || "pending";
  const id = `ask_user:${toolCallId}`;
  const msg = {
    id,
    role: "assistant",
    parts: [],
    __askUser: askUserState,
  } as any as Message;
  const out = messages.slice();
  out.push(msg);
  return out;
}

export function buildChatLayoutModel(
  opts: Readonly<{
    messages: readonly Message[];
    width: number;
    selectedToolCallId: string | null;
    expandedBashOutputs: ReadonlySet<string>;
    expandedToolOutputLineCounts?: ReadonlyMap<string, number>;
    collapsedToolCallIds: ReadonlySet<string>;
    expandedToolCallGroups?: ReadonlySet<string>;
    unifiedEditDiffToolCallIds?: ReadonlySet<string>;
    expandToolCallsAndThinking?: boolean;
    pendingToolCallIds?: ReadonlySet<string>;
    activeApprovalId?: string | null;
    activeApprovalActionIndex?: number;
    suppressActivePendingApprovalInline?: boolean;
    askUserState?: AskUserInlineState | null;
    toolCallBlinkOn?: ToolCallBlinker;
    thinkingHeaderPulse?: Readonly<{
      messageId: string;
      partIndex: number;
      frame: number;
    }> | null;
    streamingMarkdownReveal?: StreamingMarkdownReveal | null;
    deferredTerminalMarkdownUpgrade?: Readonly<{
      messageId: string;
      partIndex: number;
    }> | null;
    theme: ThemeState;
    ui?: UiThemeContext;
  }>,
): ChatLayoutModel {
  const out: Line[] = [];
  const theme = opts.theme;
  const themeRaw = (opts.theme as any)?.mode ?? (opts.theme as any)?.preset;
  const fallbackTheme = resolveUiThemeForMode(themeRaw);
  const ui = opts.ui ?? createUiThemeContext(fallbackTheme, { mode: "truecolor", level: 256 });
  const unifiedEditDiffToolCallIds = opts.unifiedEditDiffToolCallIds ?? EMPTY_STRING_SET;
  const expandToolCallsAndThinking = Boolean(opts.expandToolCallsAndThinking);

  const width = Math.max(0, opts.width);
  let blockIndex = 0;

  const latestAssistantMessageId = findLatestAssistantMessageId(opts.messages);
  const renderable = appendAskUserMessage(opts.messages, opts.askUserState);

  const expandedToolCallGroups = opts.expandedToolCallGroups ?? EMPTY_STRING_SET;

  for (const m of renderable) {
    const block = renderMessageBlock(
      m,
      latestAssistantMessageId,
      width,
      opts.selectedToolCallId,
      opts.expandedBashOutputs,
      opts.expandedToolOutputLineCounts ?? EMPTY_NUMBER_MAP,
      opts.collapsedToolCallIds,
      expandedToolCallGroups,
      unifiedEditDiffToolCallIds,
      expandToolCallsAndThinking,
      opts.pendingToolCallIds,
      opts.activeApprovalId,
      opts.activeApprovalActionIndex,
      opts.suppressActivePendingApprovalInline,
      opts.toolCallBlinkOn,
      opts.thinkingHeaderPulse,
      opts.streamingMarkdownReveal,
      opts.deferredTerminalMarkdownUpgrade,
      theme,
      ui,
    );
    if (!block) continue;

    const decorated = decorateBlock(block, theme, ui, blockIndex === 0);
    out.push(...decorated);
    blockIndex++;
  }

  const toolLineById = new Map<string, number>();
  const messageLineById = new Map<string, number>();
  for (let i = 0; i < out.length; i++) {
    const mid = String((out[i] as any)?.messageId ?? "").trim();
    if (mid && !messageLineById.has(mid)) messageLineById.set(mid, i);
    const a = out[i]?.action;
    if (a?.type === "selectToolCall") toolLineById.set(a.callId, i);
    else if (a?.type === "selectToolCallGroup") toolLineById.set(a.groupKey, i);
  }

  const lineCount = out.length;
  const getLines = (start: number, end: number) => {
    const safeStart = Math.max(0, Math.floor(start));
    const safeEnd = Math.max(safeStart, Math.floor(end));
    return out.slice(safeStart, safeEnd);
  };
  const getLine = (row: number) => {
    const idx = Math.floor(row);
    if (!Number.isFinite(idx) || idx < 0 || idx >= lineCount) return undefined;
    return out[idx];
  };
  return {
    lineCount,
    getLines,
    getLine,
    toolLineById,
    messageLineById,
    lines: out,
  };
}

export function createChatLayoutModelBuilder(): (
  opts: ChatLayoutModelBuildOpts,
) => ChatLayoutModel {
  let lastWidth = -1;
  let lastSelectedToolCallId: string | null = null;
  let lastExpandedBashOutputs = new Set<string>();
  let lastExpandedToolOutputLineCounts = new Map<string, number>();
  let lastCollapsedToolCallIds = new Set<string>();
  let lastExpandedToolCallGroups = new Set<string>();
  let lastUnifiedEditDiffToolCallIds = new Set<string>();
  let lastExpandToolCallsAndThinking = false;
  let lastActiveApprovalToolCallId: string | null = null;
  let lastActiveApprovalActionIndex = 0;
  let lastSuppressActivePendingApprovalInline = false;
  let lastLatestAssistantMessageId: string | null = null;
  let lastThemeSig = "";
  let lastUiSig = "";
  let lastDirtySeq = -1;
  let lastSlowLogAt = 0;
  let lastSlowMessageLogAt = 0;

  // Track volatile animation state and tail message refs to skip tail
  // fingerprinting when nothing could have changed.
  let lastTailVolatileSig = "";
  let lastTailRefs: (Message | undefined)[] = [];

  let totalLines = 0;
  const messageIds: string[] = [];
  const messageStarts: number[] = [];
  const messageLineCounts: number[] = [];
  const messageIndexById = new Map<string, number>();
  const messageMetaById = new Map<string, MessageLayoutMeta>();
  const messageCache = new Map<string, { lines: Line[]; lastUsed: number }>();
  const toolMessageIdByCallId = new Map<string, string>();

  let renderable: Message[] = [];
  let publicToolLineById = new Map<string, number>();
  let publicMessageLineById = new Map<string, number>();

  let perfEnabled = false;
  let breakdownEnabled = false;
  let cacheClock = 0;

  const maxCachedMessages = (() => {
    const raw = String(
      (globalThis as any)?.process?.env?.DIMCODE_CHAT_RENDER_CACHE_MESSAGES ?? "",
    ).trim();
    const n = Number(raw);
    if (Number.isFinite(n) && n > 0) return Math.min(50_000, Math.max(50, Math.floor(n)));
    return 300;
  })();

  let lastRenderOpts: Readonly<{
    latestAssistantMessageId: string | null;
    width: number;
    selectedToolCallId: string | null;
    expandedBashOutputs: ReadonlySet<string>;
    expandedToolOutputLineCounts: ReadonlyMap<string, number>;
    collapsedToolCallIds: ReadonlySet<string>;
    expandedToolCallGroups: ReadonlySet<string>;
    unifiedEditDiffToolCallIds: ReadonlySet<string>;
    expandToolCallsAndThinking: boolean;
    pendingToolCallIds?: ReadonlySet<string>;
    activeApprovalId?: string | null;
    activeApprovalActionIndex?: number;
    suppressActivePendingApprovalInline?: boolean;
    toolCallBlinkOn?: ToolCallBlinker;
    thinkingHeaderPulse?: ThinkingHeaderPulse | null;
    streamingMarkdownReveal?: StreamingMarkdownReveal | null;
    deferredTerminalMarkdownUpgrade?: Readonly<{
      messageId: string;
      partIndex: number;
    }> | null;
    theme: ThemeState;
    ui: UiThemeContext;
  }> | null = null;

  function logPerf(line: string): void {
    if (!perfEnabled) return;
    perfLogLine(line);
  }

  function pruneCache(): void {
    if (messageCache.size <= maxCachedMessages) return;
    const entries = Array.from(messageCache.entries());
    entries.sort((a, b) => a[1].lastUsed - b[1].lastUsed);
    const overflow = messageCache.size - maxCachedMessages;
    for (let i = 0; i < overflow; i++) {
      const id = entries[i]?.[0];
      if (id) messageCache.delete(id);
    }
  }

  function renderMessage(
    m: Message,
    opts: Readonly<{
      latestAssistantMessageId: string | null;
      width: number;
      selectedToolCallId: string | null;
      expandedBashOutputs: ReadonlySet<string>;
      expandedToolOutputLineCounts: ReadonlyMap<string, number>;
      collapsedToolCallIds: ReadonlySet<string>;
      expandedToolCallGroups: ReadonlySet<string>;
      unifiedEditDiffToolCallIds: ReadonlySet<string>;
      expandToolCallsAndThinking: boolean;
      pendingToolCallIds?: ReadonlySet<string>;
      activeApprovalId?: string | null;
      activeApprovalActionIndex?: number;
      suppressActivePendingApprovalInline?: boolean;
      toolCallBlinkOn?: ToolCallBlinker;
      thinkingHeaderPulse?: ThinkingHeaderPulse | null;
      streamingMarkdownReveal?: StreamingMarkdownReveal | null;
      deferredTerminalMarkdownUpgrade?: Readonly<{
        messageId: string;
        partIndex: number;
      }> | null;
      theme: ThemeState;
      ui: UiThemeContext;
    }>,
    isFirstBlock: boolean,
  ): { lines: Line[]; meta: MessageLayoutMeta } | null {
    const block = renderMessageBlock(
      m,
      opts.latestAssistantMessageId,
      opts.width,
      opts.selectedToolCallId,
      opts.expandedBashOutputs,
      opts.expandedToolOutputLineCounts,
      opts.collapsedToolCallIds,
      opts.expandedToolCallGroups,
      opts.unifiedEditDiffToolCallIds,
      opts.expandToolCallsAndThinking,
      opts.pendingToolCallIds,
      opts.activeApprovalId,
      opts.activeApprovalActionIndex,
      opts.suppressActivePendingApprovalInline,
      opts.toolCallBlinkOn,
      opts.thinkingHeaderPulse,
      opts.streamingMarkdownReveal,
      opts.deferredTerminalMarkdownUpgrade,
      opts.theme,
      opts.ui,
    );
    if (!block) return null;
    const lines = decorateBlock(block, opts.theme, opts.ui, isFirstBlock);
    const toolLines = extractToolLines(lines);
    const meta: MessageLayoutMeta = {
      fp: fingerprintMessage(
        m,
        opts.latestAssistantMessageId,
        opts.pendingToolCallIds,
        opts.toolCallBlinkOn,
        opts.thinkingHeaderPulse,
        opts.streamingMarkdownReveal,
        opts.deferredTerminalMarkdownUpgrade,
      ),
      lineCount: lines.length,
      toolLines,
      firstMessageLineOffset: firstMessageLineOffset(lines, m.id),
    };
    return { lines, meta };
  }

  function rebuildToolMaps(): void {
    const nextTool = new Map<string, number>();
    const nextMessageLineById = new Map<string, number>();
    for (let i = 0; i < messageIds.length; i++) {
      const id = messageIds[i]!;
      const start = messageStarts[i] ?? 0;
      const meta = messageMetaById.get(id);
      const firstOffset = meta?.firstMessageLineOffset ?? 0;
      nextMessageLineById.set(id, start + firstOffset);
      if (!meta) continue;
      for (const tl of meta.toolLines) nextTool.set(tl.callId, start + tl.line);
    }
    publicToolLineById = nextTool;
    publicMessageLineById = nextMessageLineById;
  }

  function updateToolMessageMap(id: string, meta: MessageLayoutMeta | undefined): void {
    for (const [callId, mid] of toolMessageIdByCallId.entries()) {
      if (mid === id) toolMessageIdByCallId.delete(callId);
    }
    if (!meta) return;
    for (const tl of meta.toolLines) toolMessageIdByCallId.set(tl.callId, id);
  }

  function fullRebuild(
    opts: Readonly<{
      messages: readonly Message[];
      latestAssistantMessageId: string | null;
      width: number;
      selectedToolCallId: string | null;
      expandedBashOutputs: ReadonlySet<string>;
      expandedToolOutputLineCounts: ReadonlyMap<string, number>;
      collapsedToolCallIds: ReadonlySet<string>;
      expandedToolCallGroups: ReadonlySet<string>;
      unifiedEditDiffToolCallIds: ReadonlySet<string>;
      expandToolCallsAndThinking: boolean;
      pendingToolCallIds?: ReadonlySet<string>;
      activeApprovalId?: string | null;
      activeApprovalActionIndex?: number;
      suppressActivePendingApprovalInline?: boolean;
      toolCallBlinkOn?: ToolCallBlinker;
      thinkingHeaderPulse?: ThinkingHeaderPulse | null;
      streamingMarkdownReveal?: StreamingMarkdownReveal | null;
      deferredTerminalMarkdownUpgrade?: Readonly<{
        messageId: string;
        partIndex: number;
      }> | null;
      theme: ThemeState;
      ui: UiThemeContext;
    }>,
  ): void {
    totalLines = 0;
    messageIds.length = 0;
    messageStarts.length = 0;
    messageLineCounts.length = 0;
    messageIndexById.clear();
    messageMetaById.clear();
    messageCache.clear();
    toolMessageIdByCallId.clear();

    for (let i = 0; i < opts.messages.length; i++) {
      const m = opts.messages[i]!;
      const rendered = renderMessage(m, opts, i === 0);
      if (!rendered) continue;
      const id = m.id;
      const start = totalLines;
      const count = rendered.meta.lineCount;
      messageIds.push(id);
      messageStarts.push(start);
      messageLineCounts.push(count);
      messageIndexById.set(id, messageIds.length - 1);
      messageMetaById.set(id, rendered.meta);
      messageCache.set(id, { lines: rendered.lines, lastUsed: ++cacheClock });
      updateToolMessageMap(id, rendered.meta);
      totalLines += count;
    }
    pruneCache();
  }

  function replaceMessageAt(
    index: number,
    m: Message,
    opts: Readonly<{
      latestAssistantMessageId: string | null;
      width: number;
      selectedToolCallId: string | null;
      expandedBashOutputs: ReadonlySet<string>;
      expandedToolOutputLineCounts: ReadonlyMap<string, number>;
      collapsedToolCallIds: ReadonlySet<string>;
      expandedToolCallGroups: ReadonlySet<string>;
      unifiedEditDiffToolCallIds: ReadonlySet<string>;
      expandToolCallsAndThinking: boolean;
      pendingToolCallIds?: ReadonlySet<string>;
      activeApprovalId?: string | null;
      activeApprovalActionIndex?: number;
      suppressActivePendingApprovalInline?: boolean;
      toolCallBlinkOn?: ToolCallBlinker;
      thinkingHeaderPulse?: ThinkingHeaderPulse | null;
      streamingMarkdownReveal?: StreamingMarkdownReveal | null;
      deferredTerminalMarkdownUpgrade?: Readonly<{
        messageId: string;
        partIndex: number;
      }> | null;
      theme: ThemeState;
      ui: UiThemeContext;
    }>,
  ): void {
    const id = m.id;
    const prevCount = messageLineCounts[index] ?? 0;

    const t0 = breakdownEnabled ? Date.now() : 0;
    const tRender0 = breakdownEnabled ? Date.now() : 0;
    const rendered = renderMessage(m, opts, index === 0);
    if (!rendered) return;
    const tRender1 = breakdownEnabled ? Date.now() : 0;

    messageMetaById.set(id, rendered.meta);
    messageCache.set(id, { lines: rendered.lines, lastUsed: ++cacheClock });
    updateToolMessageMap(id, rendered.meta);

    const newCount = rendered.meta.lineCount;
    messageLineCounts[index] = newCount;
    const delta = newCount - prevCount;
    if (delta !== 0) {
      for (let j = index + 1; j < messageStarts.length; j++)
        messageStarts[j] = (messageStarts[j] ?? 0) + delta;
      totalLines += delta;
    }

    if (breakdownEnabled) {
      const elapsed = Date.now() - t0;
      const threshold = isOneShotEnabled() ? 10 : 60;
      if (elapsed >= threshold) {
        const now = Date.now();
        if (isOneShotEnabled() || now - lastSlowMessageLogAt >= 500) {
          lastSlowMessageLogAt = now;
          const role = m.role;
          const renderMs = tRender1 - tRender0;

          let partsSummary = "";
          if (role === "assistant") {
            const parts = (m as any).parts as any[] | undefined;
            if (Array.isArray(parts)) {
              let mdChars = 0;
              let toolCalls = 0;
              let toolOutputs = 0;
              let toolOutputChars = 0;
              let toolResults = 0;
              let toolResultChars = 0;
              let argsTextChars = 0;
              for (const p of parts) {
                const t = String(p?.type ?? "");
                if (t === "markdown") {
                  mdChars += String(p?.markdown ?? "").length;
                } else if (t === "tool_call") {
                  toolCalls++;
                  argsTextChars += String(p?.call?.argumentsText ?? "").length;
                } else if (t === "tool_output") {
                  toolOutputs++;
                  toolOutputChars += String(p?.output ?? "").length;
                } else if (t === "tool_result") {
                  toolResults++;
                  toolResultChars += String(p?.result?.output ?? "").length;
                }
              }
              partsSummary = ` parts=${parts.length} mdChars=${mdChars} tc=${toolCalls} argsTextChars=${argsTextChars} to=${toolOutputs} toChars=${toolOutputChars} tr=${toolResults} trChars=${toolResultChars}`;
            }
          }

          logPerf(
            `[LAYOUT_MSG] ${elapsed}ms id=${id} role=${role} render=${renderMs}ms oldLines=${prevCount} newLines=${newCount}${partsSummary}`,
          );
        }
      }
    }
  }

  function findMessageIndexForLine(line: number): number {
    if (messageStarts.length === 0) return -1;
    let lo = 0;
    let hi = messageStarts.length - 1;
    let candidate = -1;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      const start = messageStarts[mid] ?? 0;
      if (start <= line) {
        candidate = mid;
        lo = mid + 1;
      } else {
        hi = mid - 1;
      }
    }
    if (candidate < 0) return 0;
    const end = (messageStarts[candidate] ?? 0) + (messageLineCounts[candidate] ?? 0);
    if (line < end) return candidate;
    return Math.min(candidate + 1, messageStarts.length);
  }

  function getCachedLines(id: string, index: number): Line[] | null {
    const cached = messageCache.get(id);
    if (cached) {
      cached.lastUsed = ++cacheClock;
      return cached.lines;
    }
    if (!lastRenderOpts) return null;
    const m = renderable[index];
    if (!m) return null;
    const rendered = renderMessage(m, lastRenderOpts, index === 0);
    if (!rendered) return null;
    messageMetaById.set(id, rendered.meta);
    messageCache.set(id, { lines: rendered.lines, lastUsed: ++cacheClock });
    updateToolMessageMap(id, rendered.meta);
    // Line count should stay consistent with cached metadata.
    const prevCount = messageLineCounts[index] ?? 0;
    const nextCount = rendered.meta.lineCount;
    if (prevCount !== nextCount) {
      const delta = nextCount - prevCount;
      messageLineCounts[index] = nextCount;
      for (let j = index + 1; j < messageStarts.length; j++)
        messageStarts[j] = (messageStarts[j] ?? 0) + delta;
      totalLines += delta;
      rebuildToolMaps();
    }
    return rendered.lines;
  }

  return (opts: ChatLayoutModelBuildOpts) => {
    perfEnabled = isPerfEnabled();
    breakdownEnabled =
      perfEnabled &&
      (isOneShotEnabled() ||
        String((globalThis as any)?.process?.env?.DIMCODE_PROFILE_LAYOUT_BREAKDOWN ?? "") === "1");
    const perfStart = perfEnabled ? Date.now() : 0;

    const width = Math.max(0, opts.width);
    const selectedToolCallId = opts.selectedToolCallId ?? null;
    const activeApprovalToolCallId = opts.activeApprovalToolCallId ?? null;
    const activeApprovalActionIndex = Math.max(
      0,
      Math.min(2, Math.floor(Number(opts.activeApprovalActionIndex ?? 0))),
    );
    const suppressActivePendingApprovalInline = Boolean(opts.suppressActivePendingApprovalInline);
    const unifiedEditDiffToolCallIds = opts.unifiedEditDiffToolCallIds ?? EMPTY_STRING_SET;
    const expandedToolOutputLineCounts = opts.expandedToolOutputLineCounts ?? EMPTY_NUMBER_MAP;
    const expandToolCallsAndThinking = Boolean(opts.expandToolCallsAndThinking);
    const themeRaw2 = (opts.theme as any)?.mode ?? (opts.theme as any)?.preset;
    const fallbackTheme2 = resolveUiThemeForMode(themeRaw2);
    const ui = opts.ui ?? createUiThemeContext(fallbackTheme2, { mode: "truecolor", level: 256 });
    const themeSig = themeSignature(opts.theme);
    const uiSig = `${ui.capability.mode}:${ui.capability.level}`;
    let changed = false;

    const expandedToolCallGroups = opts.expandedToolCallGroups ?? EMPTY_STRING_SET;
    const latestAssistantMessageId = findLatestAssistantMessageId(opts.messages);
    lastRenderOpts = {
      latestAssistantMessageId,
      width,
      selectedToolCallId,
      expandedBashOutputs: opts.expandedBashOutputs,
      expandedToolOutputLineCounts,
      collapsedToolCallIds: opts.collapsedToolCallIds,
      expandedToolCallGroups,
      unifiedEditDiffToolCallIds,
      expandToolCallsAndThinking,
      pendingToolCallIds: opts.pendingToolCallIds,
      activeApprovalId: opts.activeApprovalId ?? null,
      activeApprovalActionIndex,
      suppressActivePendingApprovalInline,
      toolCallBlinkOn: opts.toolCallBlinkOn,
      thinkingHeaderPulse: opts.thinkingHeaderPulse,
      streamingMarkdownReveal: opts.streamingMarkdownReveal,
      deferredTerminalMarkdownUpgrade: opts.deferredTerminalMarkdownUpgrade,
      theme: opts.theme,
      ui,
    };
    const renderOpts = lastRenderOpts!;

    const mustRebuildAll =
      width !== lastWidth ||
      themeSig !== lastThemeSig ||
      uiSig !== lastUiSig ||
      expandToolCallsAndThinking !== lastExpandToolCallsAndThinking ||
      suppressActivePendingApprovalInline !== lastSuppressActivePendingApprovalInline;
    if (mustRebuildAll) {
      renderable = opts.messages.filter((m) => m.role !== "tool");
      renderable = appendAskUserMessage(renderable, opts.askUserState);
      fullRebuild({ ...renderOpts, messages: renderable });
      lastWidth = width;
      lastThemeSig = themeSig;
      lastUiSig = uiSig;
      lastSelectedToolCallId = selectedToolCallId;
      lastActiveApprovalToolCallId = activeApprovalToolCallId;
      lastActiveApprovalActionIndex = activeApprovalActionIndex;
      lastSuppressActivePendingApprovalInline = suppressActivePendingApprovalInline;
      lastExpandedBashOutputs = new Set(opts.expandedBashOutputs);
      lastExpandedToolOutputLineCounts = new Map(expandedToolOutputLineCounts);
      lastCollapsedToolCallIds = new Set(opts.collapsedToolCallIds);
      lastExpandedToolCallGroups = new Set(expandedToolCallGroups);
      lastUnifiedEditDiffToolCallIds = new Set(unifiedEditDiffToolCallIds);
      lastExpandToolCallsAndThinking = expandToolCallsAndThinking;
      lastLatestAssistantMessageId = latestAssistantMessageId;
      changed = true;
    } else {
      const nextRenderable: Message[] = [];
      for (const m of opts.messages) {
        if (m.role === "tool") continue;
        nextRenderable.push(m);
      }
      renderable = appendAskUserMessage(nextRenderable, opts.askUserState);

      if (renderable.length < messageIds.length) {
        fullRebuild({ ...renderOpts, messages: renderable });
        changed = true;
      } else {
        let needsRebuild = false;
        for (let i = 0; i < messageIds.length; i++) {
          if (renderable[i]?.id !== messageIds[i]) {
            needsRebuild = true;
            break;
          }
        }
        if (needsRebuild) {
          fullRebuild({ ...renderOpts, messages: renderable });
          changed = true;
        } else {
          for (let i = messageIds.length; i < renderable.length; i++) {
            const m = renderable[i]!;
            const rendered = renderMessage(m, renderOpts, i === 0);
            if (!rendered) continue;
            const id = m.id;
            const start = totalLines;
            const count = rendered.meta.lineCount;
            messageIds.push(id);
            messageStarts.push(start);
            messageLineCounts.push(count);
            messageIndexById.set(id, messageIds.length - 1);
            messageMetaById.set(id, rendered.meta);
            messageCache.set(id, {
              lines: rendered.lines,
              lastUsed: ++cacheClock,
            });
            updateToolMessageMap(id, rendered.meta);
            totalLines += count;
            changed = true;
          }
        }
      }

      const latestAssistantChanged =
        lastLatestAssistantMessageId !== renderOpts.latestAssistantMessageId;
      if (latestAssistantChanged) {
        const affectedMessageIds = new Set<string>();
        if (lastLatestAssistantMessageId) affectedMessageIds.add(lastLatestAssistantMessageId);
        if (renderOpts.latestAssistantMessageId)
          affectedMessageIds.add(renderOpts.latestAssistantMessageId);
        for (const messageId of affectedMessageIds) {
          const idx = messageIndexById.get(messageId);
          const m = typeof idx === "number" ? renderable[idx] : null;
          if (!m || idx == null) continue;
          replaceMessageAt(idx, m, renderOpts);
          changed = true;
        }
      }

      // Selection/expand state is UI-only and can change without message content changing.
      // Avoid full rebuild: only re-render messages that contain the affected tool_call.
      const affectedToolCallIds = new Set<string>();
      const selectionChanged = lastSelectedToolCallId !== selectedToolCallId;
      if (selectionChanged) {
        if (lastSelectedToolCallId) affectedToolCallIds.add(lastSelectedToolCallId);
        if (selectedToolCallId) affectedToolCallIds.add(selectedToolCallId);
      }
      const approvalChanged = lastActiveApprovalToolCallId !== activeApprovalToolCallId;
      if (approvalChanged) {
        if (lastActiveApprovalToolCallId) affectedToolCallIds.add(lastActiveApprovalToolCallId);
        if (activeApprovalToolCallId) affectedToolCallIds.add(activeApprovalToolCallId);
      }
      const approvalActionChanged = lastActiveApprovalActionIndex !== activeApprovalActionIndex;
      if (approvalActionChanged && activeApprovalToolCallId)
        affectedToolCallIds.add(activeApprovalToolCallId);
      let expandedChanged = false;
      for (const id of lastExpandedBashOutputs) {
        if (!opts.expandedBashOutputs.has(id)) {
          affectedToolCallIds.add(id);
          expandedChanged = true;
        }
      }
      for (const id of opts.expandedBashOutputs) {
        if (!lastExpandedBashOutputs.has(id)) {
          affectedToolCallIds.add(id);
          expandedChanged = true;
        }
      }

      let expandedLineCountsChanged = false;
      for (const [id, count] of lastExpandedToolOutputLineCounts.entries()) {
        if (expandedToolOutputLineCounts.get(id) !== count) {
          affectedToolCallIds.add(id);
          expandedLineCountsChanged = true;
        }
      }
      for (const [id, count] of expandedToolOutputLineCounts.entries()) {
        if (lastExpandedToolOutputLineCounts.get(id) !== count) {
          affectedToolCallIds.add(id);
          expandedLineCountsChanged = true;
        }
      }

      let collapsedChanged = false;
      for (const id of lastCollapsedToolCallIds) {
        if (!opts.collapsedToolCallIds.has(id)) {
          affectedToolCallIds.add(id);
          collapsedChanged = true;
        }
      }
      for (const id of opts.collapsedToolCallIds) {
        if (!lastCollapsedToolCallIds.has(id)) {
          affectedToolCallIds.add(id);
          collapsedChanged = true;
        }
      }

      let groupsChanged = false;
      for (const id of lastExpandedToolCallGroups) {
        if (!expandedToolCallGroups.has(id)) {
          affectedToolCallIds.add(id);
          groupsChanged = true;
        }
      }
      for (const id of expandedToolCallGroups) {
        if (!lastExpandedToolCallGroups.has(id)) {
          affectedToolCallIds.add(id);
          groupsChanged = true;
        }
      }

      let unifiedChanged = false;
      for (const id of lastUnifiedEditDiffToolCallIds) {
        if (!unifiedEditDiffToolCallIds.has(id)) {
          affectedToolCallIds.add(id);
          unifiedChanged = true;
        }
      }
      for (const id of unifiedEditDiffToolCallIds) {
        if (!lastUnifiedEditDiffToolCallIds.has(id)) {
          affectedToolCallIds.add(id);
          unifiedChanged = true;
        }
      }

      if (affectedToolCallIds.size > 0) {
        const affectedMessageIds = new Set<string>();
        for (const callId of affectedToolCallIds) {
          const mid = toolMessageIdByCallId.get(callId);
          if (mid) affectedMessageIds.add(mid);
        }
        for (const mid of affectedMessageIds) {
          const idx = messageIndexById.get(mid);
          const m = typeof idx === "number" ? renderable[idx] : null;
          if (!m || idx == null) continue;
          replaceMessageAt(idx, m, renderOpts);
          changed = true;
        }
        lastSelectedToolCallId = selectedToolCallId;
        lastActiveApprovalToolCallId = activeApprovalToolCallId;
        lastActiveApprovalActionIndex = activeApprovalActionIndex;
        lastSuppressActivePendingApprovalInline = suppressActivePendingApprovalInline;
        lastExpandedBashOutputs = new Set(opts.expandedBashOutputs);
        lastExpandedToolOutputLineCounts = new Map(expandedToolOutputLineCounts);
        lastCollapsedToolCallIds = new Set(opts.collapsedToolCallIds);
        lastExpandedToolCallGroups = new Set(expandedToolCallGroups);
        lastUnifiedEditDiffToolCallIds = new Set(unifiedEditDiffToolCallIds);
        expandedChanged = false;
        expandedLineCountsChanged = false;
        collapsedChanged = false;
        groupsChanged = false;
        unifiedChanged = false;
      }

      if (typeof opts.dirtySeq === "number" && opts.dirtySeq !== lastDirtySeq) {
        lastDirtySeq = opts.dirtySeq;
        const dirtyId = String(opts.dirtyMessageId ?? "").trim();
        if (dirtyId) {
          const idx = messageIndexById.get(dirtyId);
          const m = typeof idx === "number" ? renderable[idx] : null;
          if (m && idx != null) {
            const fp = fingerprintMessage(
              m,
              renderOpts.latestAssistantMessageId,
              opts.pendingToolCallIds,
              opts.toolCallBlinkOn,
              opts.thinkingHeaderPulse,
              opts.streamingMarkdownReveal,
              renderOpts.deferredTerminalMarkdownUpgrade,
            );
            const prev = messageMetaById.get(dirtyId)?.fp;
            if (fp !== prev) {
              replaceMessageAt(idx, m, renderOpts);
              changed = true;
            }
          }
        }
      }

      // Keep this in sync with `markLayoutDirtyIfNonTail` in `core/store/message-mutations.ts`.
      // The last N renderable messages are considered the "tail" and refreshed on every frame.
      //
      // PERF: Build a cheap volatile-state signature and compare tail message
      // object references. Skip the expensive fingerprint loop entirely when
      // nothing could have changed (no streaming, no blinking, no pulse, no
      // content mutations, and same message objects as last frame). This
      // eliminates per-frame fingerprinting overhead during idle scrolling.
      const tail = 5;
      const startIdx = Math.max(0, renderable.length - tail);
      const tailLen = renderable.length - startIdx;

      const hasPendingBlink = Boolean(
        opts.pendingToolCallIds &&
        opts.pendingToolCallIds.size > 0 &&
        typeof opts.toolCallBlinkOn === "function",
      );
      const sr = opts.streamingMarkdownReveal;
      const thp = opts.thinkingHeaderPulse;
      const dt = renderOpts.deferredTerminalMarkdownUpgrade;
      const tailVolatileSig = `${sr?.messageId ?? ""}:${sr?.visibleChars ?? ""}|${thp?.messageId ?? ""}:${thp?.frame ?? ""}|${dt?.messageId ?? ""}:${dt?.partIndex ?? ""}`;
      const tailVolatileChanged = tailVolatileSig !== lastTailVolatileSig;
      lastTailVolatileSig = tailVolatileSig;

      // Cheap reference check: did any tail message object get replaced?
      let tailRefsChanged = lastTailRefs.length !== tailLen;
      if (!tailRefsChanged) {
        for (let i = 0; i < tailLen; i++) {
          if (renderable[startIdx + i] !== lastTailRefs[i]) {
            tailRefsChanged = true;
            break;
          }
        }
      }
      lastTailRefs = renderable.slice(startIdx);

      if (tailRefsChanged || tailVolatileChanged || changed || hasPendingBlink) {
        for (let i = startIdx; i < renderable.length; i++) {
          const m = renderable[i]!;
          const id = m.id;
          const fp = fingerprintMessage(
            m,
            renderOpts.latestAssistantMessageId,
            opts.pendingToolCallIds,
            opts.toolCallBlinkOn,
            opts.thinkingHeaderPulse,
            opts.streamingMarkdownReveal,
            renderOpts.deferredTerminalMarkdownUpgrade,
          );
          const prev = messageMetaById.get(id)?.fp;
          if (fp !== prev) {
            replaceMessageAt(i, m, renderOpts);
            changed = true;
          }
        }
      }

      // PERF/Correctness: tool call "pending" highlighting/blinking is keyed off
      // `pendingToolCallIds` + `toolCallBlinkOn`, but the pending tool_call message isn't
      // guaranteed to be in the last N messages when the user enqueues messages while a
      // tool is running. Refresh the affected messages explicitly.
      if (
        opts.pendingToolCallIds &&
        opts.pendingToolCallIds.size > 0 &&
        typeof opts.toolCallBlinkOn === "function"
      ) {
        const affectedMessageIds = new Set<string>();
        for (const callId of opts.pendingToolCallIds) {
          const mid = toolMessageIdByCallId.get(callId);
          if (mid) affectedMessageIds.add(mid);
        }
        for (const mid of affectedMessageIds) {
          const idx = messageIndexById.get(mid);
          const m = typeof idx === "number" ? renderable[idx] : null;
          if (!m || idx == null) continue;
          const fp = fingerprintMessage(
            m,
            renderOpts.latestAssistantMessageId,
            opts.pendingToolCallIds,
            opts.toolCallBlinkOn,
            opts.thinkingHeaderPulse,
            opts.streamingMarkdownReveal,
            renderOpts.deferredTerminalMarkdownUpgrade,
          );
          const prev = messageMetaById.get(mid)?.fp;
          if (fp !== prev) {
            replaceMessageAt(idx, m, renderOpts);
            changed = true;
          }
        }
      }

      // Keep last selection/expand state in sync even if no tool_call was present yet.
      if (selectionChanged && lastSelectedToolCallId !== selectedToolCallId)
        lastSelectedToolCallId = selectedToolCallId;
      if (expandedChanged) lastExpandedBashOutputs = new Set(opts.expandedBashOutputs);
      if (expandedLineCountsChanged)
        lastExpandedToolOutputLineCounts = new Map(expandedToolOutputLineCounts);
      if (collapsedChanged) lastCollapsedToolCallIds = new Set(opts.collapsedToolCallIds);
      if (groupsChanged) lastExpandedToolCallGroups = new Set(expandedToolCallGroups);
      if (unifiedChanged) lastUnifiedEditDiffToolCallIds = new Set(unifiedEditDiffToolCallIds);
      if (latestAssistantChanged)
        lastLatestAssistantMessageId = renderOpts.latestAssistantMessageId;
    }

    if (changed) {
      rebuildToolMaps();
      // Prune after mutations (not in getLines) to keep the scroll hot-path lean.
      pruneCache();
    }

    const getLines = (start: number, end: number) => {
      const safeStart = Math.max(0, Math.floor(start));
      const safeEnd = Math.min(totalLines, Math.max(safeStart, Math.floor(end)));
      if (safeEnd <= safeStart) return [];
      const out: Line[] = [];
      let idx = findMessageIndexForLine(safeStart);
      if (idx < 0) return out;
      for (; idx < messageIds.length; idx++) {
        const id = messageIds[idx]!;
        const msgStart = messageStarts[idx] ?? 0;
        if (msgStart >= safeEnd) break;
        const linesForMsg = getCachedLines(id, idx);
        if (!linesForMsg) continue;
        const from = Math.max(0, safeStart - msgStart);
        const to = Math.min(linesForMsg.length, safeEnd - msgStart);
        if (to > from) {
          for (let j = from; j < to; j++) out.push(linesForMsg[j]!);
        }
      }
      return out;
    };

    const getLine = (row: number) => {
      const idxRow = Math.floor(row);
      if (!Number.isFinite(idxRow) || idxRow < 0 || idxRow >= totalLines) return undefined;
      const msgIdx = findMessageIndexForLine(idxRow);
      if (msgIdx < 0 || msgIdx >= messageIds.length) return undefined;
      const id = messageIds[msgIdx]!;
      const msgStart = messageStarts[msgIdx] ?? 0;
      const linesForMsg = getCachedLines(id, msgIdx);
      if (!linesForMsg) return undefined;
      const offset = idxRow - msgStart;
      return linesForMsg[offset];
    };

    if (perfEnabled) {
      const elapsed = Date.now() - perfStart;
      const threshold = isOneShotEnabled() ? 5 : 40;
      if (elapsed >= threshold) {
        const now = Date.now();
        if (isOneShotEnabled() || now - lastSlowLogAt >= 800) {
          lastSlowLogAt = now;
          const msgCount = opts.messages.length;
          const renderableCount = renderable.length;
          logPerf(
            `[LAYOUT] ${elapsed}ms width=${width} msgs=${msgCount} renderable=${renderableCount} lines=${totalLines} changed=${changed ? 1 : 0}`,
          );
        }
      }
    }

    let linesSnapshot: Line[] | null = null;
    const getSnapshot = () => {
      if (!linesSnapshot) linesSnapshot = getLines(0, totalLines);
      return linesSnapshot;
    };

    return {
      lineCount: totalLines,
      getLines,
      getLine,
      toolLineById: publicToolLineById,
      messageLineById: publicMessageLineById,
      get lines() {
        return getSnapshot();
      },
    };
  };
}
