import { type Component, Container, type NativeScrollbackLiveRegion, type RenderStablePrefix } from './stubs/tui/index.ts';

const kSnapshot = Symbol("transcript.liveDiffSnapshot");

/**
 * Per-block diff cache: the block's previous stripped contribution plus the
 * derived append-only state. Purely an input to {@link deriveLiveCommitState}
 * for still-live blocks — it is never replayed as render output. Every block
 * renders its current content on every frame.
 */
interface LiveDiffSnapshot {
	width: number;
	lines: readonly string[];
	generation: number;
	appendOnly: boolean;
	/**
	 * Frames remaining until a block that rewrote an interior row may re-earn
	 * append-only status. `0` means the block is not under rewrite suspicion.
	 */
	volatileCooldown: number;
	/**
	 * Stable-prefix ratchet (see {@link deriveLiveCommitState}): leading rows
	 * promoted as commit-safe because they stayed visibly identical for
	 * {@link STABLE_PREFIX_COMMIT_FRAMES} consecutive frames, plus the in-flight
	 * candidate run and its age.
	 */
	stablePrefixLength: number;
	candidatePrefixLength: number;
	candidatePrefixAge: number;
	/**
	 * Topmost row index ever observed rewritten in place (see
	 * {@link deriveLiveCommitState}): the stable-prefix ratchet never promotes
	 * rows at/after it. `Infinity` until the first rewrite.
	 */
	rewriteFloor: number;
}

interface SnapshotCarrier {
	[kSnapshot]?: LiveDiffSnapshot;
}

/**
 * A transcript block that is still mutating (a foreground tool awaiting its
 * result, an assistant message mid-stream) reports `false` so the container
 * keeps it inside the live (repaintable) region instead of freezing it. Blocks
 * without the method are treated as finalized — the default, stable behavior.
 */
interface FinalizableBlock {
	isTranscriptBlockFinalized?(): boolean;
}

function isBlockFinalized(child: Component): boolean {
	const fn = (child as Component & FinalizableBlock).isTranscriptBlockFinalized;
	return fn ? fn.call(child) : true;
}

// A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
// separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
// to a background-colored padding row, whose escape sequences contain `\S` and
// are therefore preserved as part of a block's visual design.
const NON_WHITESPACE = /\S/;
function isPlainBlank(line: string): boolean {
	return !NON_WHITESPACE.test(line);
}

// Strip leading/trailing plain-blank rows so each block contributes only its
// visible body; the container owns the gaps between blocks. Returns the input
// array unchanged when there is nothing to trim (no allocation on the hot path).
function stripPlainBlankEdges(lines: readonly string[]): readonly string[] {
	let start = 0;
	let end = lines.length;
	while (start < end && isPlainBlank(lines[start]!)) start++;
	while (end > start && isPlainBlank(lines[end - 1]!)) end--;
	return start === 0 && end === lines.length ? lines : lines.slice(start, end);
}

/**
 * One block's recorded contribution to the assembled transcript: the raw array
 * reference its render() returned, the stripped contribution derived from it,
 * and where those rows landed. Reference-compared on the next render — per the
 * Component render contract, an identical raw reference proves the block's
 * rows are byte-identical, so the stripped contribution and the assembled rows
 * can be reused without re-deriving anything.
 */
interface BlockSegment {
	component: Component;
	rawRef: readonly string[];
	contribution: readonly string[];
	width: number;
	/** Frame row of this block's first emitted row (the separator when present). */
	startRow: number;
	/** Rows emitted: separator + contribution (0 for empty contributions). */
	rowCount: number;
	sep: number;
}

const EMPTY_SEGMENTS: BlockSegment[] = [];

interface LiveCommitState {
	appendOnly: boolean;
	volatileCooldown: number;
	stablePrefixLength: number;
	candidatePrefixLength: number;
	candidatePrefixAge: number;
	rewriteFloor: number;
	safeLength: number;
}

/**
 * Render frames a block must stay clean (static or append-shaped) after an
 * interior rewrite before its rows become committable again. A one-off
 * re-layout (a codespan finalizing across a wrap boundary, a paragraph
 * re-parsed as a heading) only suspends commits briefly — the pinned emitter
 * appends from the stalled high-water mark, so the gap backfills contiguously
 * once the block re-earns append-only. Periodic animations (a spinner rewrites
 * its row every few frames) keep resetting the countdown and never re-earn it,
 * so genuinely volatile blocks stay deferred. Frames arrive at most at the
 * TUI's 30 Hz render cadence, so 30 frames ≈ 1s of clean streaming.
 */
const VOLATILE_REARM_FRAMES = 30;

/**
 * Consecutive frames a leading row run must stay visibly identical before it
 * is promoted as commit-safe even though the block's tail keeps rewriting.
 * Append-only detection alone is all-or-nothing per block: one perpetually
 * ticking row (a task tool's progress tree, per-agent cost/tool counters, a
 * log line spinner) suspends commits for the WHOLE block forever, so once the
 * block outgrows the viewport its static head — e.g. a task's prompt/context
 * markdown — is neither committed to native scrollback nor on screen: the
 * transcript reads as cut off for the entire (possibly minutes-long) run.
 * The ratchet commits the settled head while only the genuinely volatile tail
 * stays deferred. If a promoted row is later rewritten (a collapsing
 * preview), the engine's committed-prefix audit re-anchors and recommits —
 * duplication, never loss — and the ratchet retreats to the divergence.
 */
const STABLE_PREFIX_COMMIT_FRAMES = 30;

/**
 * Rows at a live block's tail treated as the volatile streaming edge. Real
 * streaming is not strictly append-only at the bottom: the in-flight markdown
 * paragraph re-wraps as words arrive (rewriting its last 1-2 visual rows), an
 * unclosed token (`**bold`, a half-streamed link) re-renders when its closer
 * arrives, and a wrap-shrink moves the last word onto a new row. Divergence
 * confined to this zone is clean growth, and the zone itself is held back
 * from the offered commit boundary — so a tolerated rewrite can never touch a
 * row the engine may have committed. Width 4 covers the observed shapes (≤2
 * rows) with margin for wide glyphs and multi-row token spans; the cost is
 * only that the last 4 rows of a live block commit at finalization instead of
 * mid-stream, which is invisible (they are on screen — the viewport is always
 * taller than the holdback).
 */
const TAIL_VOLATILITY_ROWS = 4;

/**
 * Visible-content form of a row: SGR/OSC bytes and trailing pad spaces are
 * write framing, not content. A styled line's closing escape moves when the
 * line stops being the last of its span (a wrapped thinking paragraph growing
 * by one row), and width-padded rows shift their trailing spaces as text
 * grows; both leave the on-screen cells identical and must not count as a
 * rewrite of a committed-candidate row. Committed scrollback rows are written
 * with a full SGR/OSC reset terminator, so escape-placement drift between
 * visually identical renders cannot bleed styles across rows.
 */
function normalizeRow(line: string): string {
	return Bun.stripANSI(line).trimEnd();
}

function rowsVisiblyEqual(prev: string, cur: string): boolean {
	return prev === cur || normalizeRow(prev) === normalizeRow(cur);
}

/**
 * Whether `cur` is `prev` grown in place: the visible content of `prev` is a
 * strict-or-equal prefix of `cur`'s (token streaming appending to the cursor
 * row). Escape placement and pad drift are ignored, same as rowsVisiblyEqual.
 */
function rowVisiblyGrew(prev: string, cur: string): boolean {
	return normalizeRow(cur).startsWith(normalizeRow(prev));
}

function hasValidSnapshot(
	snapshot: LiveDiffSnapshot | undefined,
	width: number,
	generation: number,
): snapshot is LiveDiffSnapshot {
	return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
}

function commonPrefixLength(prev: readonly string[], cur: readonly string[]): number {
	const limit = Math.min(prev.length, cur.length);
	let i = 0;
	while (i < limit && rowsVisiblyEqual(prev[i]!, cur[i]!)) i++;
	return i;
}

function commonSuffixLength(prev: readonly string[], cur: readonly string[], prefixLength: number): number {
	const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength);
	let i = 0;
	while (i < limit && rowsVisiblyEqual(prev[prev.length - 1 - i]!, cur[cur.length - 1 - i]!)) i++;
	return i;
}

function deriveLiveCommitState(
	previous: LiveDiffSnapshot | undefined,
	current: readonly string[],
	width: number,
	generation: number,
): LiveCommitState {
	let appendOnly = false;
	let volatileCooldown = 0;
	let stablePrefixLength = 0;
	let candidatePrefixLength = 0;
	let candidatePrefixAge = 0;
	let rewriteFloor = Number.POSITIVE_INFINITY;
	let trailingRowGrowth = false;
	if (hasValidSnapshot(previous, width, generation)) {
		appendOnly = previous.appendOnly;
		volatileCooldown = previous.volatileCooldown;
		stablePrefixLength = previous.stablePrefixLength;
		candidatePrefixLength = previous.candidatePrefixLength;
		candidatePrefixAge = previous.candidatePrefixAge;
		rewriteFloor = previous.rewriteFloor;

		const prefixLength = commonPrefixLength(previous.lines, current);
		const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
		let cleanFrame = true;
		if (!staticRender) {
			const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
			// Append-only growth never rewrites a row that may already have scrolled
			// into native scrollback; it only grows the block at/near its tail. Two
			// shapes qualify:
			// - a pure insertion that preserves every previous row across a
			//   matching prefix + suffix (a bottom append, or an insertion above
			//   stable trailing chrome like a streaming tool's footer/border);
			// - a rewrite whose divergence BEGINS inside the trailing
			//   TAIL_VOLATILITY_ROWS of the previous render — the streaming edge:
			//   the in-flight paragraph re-wrapping as words arrive (its last 1-2
			//   visual rows), an unclosed markdown token (`**bold`) re-rendering
			//   when its closer streams in, a wrap-shrink pushing the last word
			//   onto an appended row. That zone is held back from `safeLength`
			//   below, so a tolerated rewrite can never touch a row that was
			//   offered for commit.
			// The anchor matters: the gap must START in the tail zone, not merely
			// be small — a one-row ticker mid-block with stable rows beneath it
			// would otherwise classify clean, get offered past, and rewrite
			// committed rows on every tick. Any deeper divergent row means the
			// block re-laid-out committed-candidate content — a rewrite, which
			// suspends commits until the block re-earns append-only.
			const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length;
			const tailConfined = preservedEveryRow || prefixLength >= previous.lines.length - TAIL_VOLATILITY_ROWS;
			if (tailConfined && current.length >= previous.lines.length) {
				// Strict trailing-row growth: every previous row except the last
				// is visibly unchanged and the last grew in place as a visible
				// prefix, with no rows appended — a line accumulating tokens.
				// The sole divergent row is the block's physical last row, which
				// the engine's window floor never commits while it stays last
				// (chunkTo ≤ windowTop ≤ last row index), so the volatile-tail
				// holdback below is unnecessary: the whole body is offerable and
				// the block's scrolled-off head reaches native scrollback.
				trailingRowGrowth =
					current.length === previous.lines.length &&
					prefixLength === previous.lines.length - 1 &&
					rowVisiblyGrew(previous.lines[prefixLength]!, current[prefixLength]!);
				if (volatileCooldown === 0) appendOnly = true;
				// Clean growth inserts/rewrites rows at the divergence; a floor
				// inside the preserved suffix travels down with it, a floor at or
				// above the divergent zone stays put (conservative: a stale floor
				// index can only point at an earlier row, never a later one).
				const delta = current.length - previous.lines.length;
				if (delta > 0 && Number.isFinite(rewriteFloor)) {
					const suffixStart = Math.max(prefixLength, previous.lines.length - suffixLength);
					if (rewriteFloor >= suffixStart) rewriteFloor += delta;
				}
			} else {
				cleanFrame = false;
				appendOnly = false;
				volatileCooldown = VOLATILE_REARM_FRAMES;
			}
		}
		if (cleanFrame && volatileCooldown > 0) volatileCooldown--;

		// Stable-prefix ratchet, independent of append-only. `prefixLength` is
		// this frame's visibly-unchanged leading run; the candidate accumulates
		// the MINIMUM prefix across a STABLE_PREFIX_COMMIT_FRAMES window, so
		// promotion means every promoted row stayed identical for the whole
		// window (row r is inside frame i's common prefix iff r < p_i, so
		// r < min(p) holds for every frame of the window). A row settling
		// mid-window promotes at most two windows later. The engine audit owns
		// any promoted rows that already committed (recommit, never loss).
		if (prefixLength < stablePrefixLength) {
			// A divergence inside the promoted run is the ratchet's proof of
			// over-promotion: this row was visibly stable for a full window,
			// got promoted (and likely committed), and then mutated anyway — a
			// slow ticker (an agent row's tool/cost counter, a growing progress
			// tree), not settling content. It will mutate again, and every
			// promote→mutate cycle makes the engine audit recommit, spraying a
			// stale snapshot of the block into native scrollback. Floor the
			// ratchet at the divergence permanently: rows above it may still
			// promote, rows at/below it never re-promote while the block lives.
			// One-off re-layouts before any promotion (a call→result frame
			// transition, a codespan finalizing) never hit this branch, and the
			// append-only re-arm path commits the full block regardless of the
			// floor.
			rewriteFloor = Math.min(rewriteFloor, prefixLength);
			stablePrefixLength = prefixLength;
			candidatePrefixLength = prefixLength;
			candidatePrefixAge = 0;
		} else {
			candidatePrefixLength =
				candidatePrefixAge === 0 ? prefixLength : Math.min(candidatePrefixLength, prefixLength);
			candidatePrefixAge++;
			if (candidatePrefixAge >= STABLE_PREFIX_COMMIT_FRAMES) {
				// Cap at the volatile-tail holdback: a long static stretch would
				// otherwise promote the streaming edge itself (min prefix == full
				// length), and the next chunk's tail re-wrap would then rewrite
				// offered rows.
				stablePrefixLength = Math.min(
					candidatePrefixLength,
					rewriteFloor,
					Math.max(0, current.length - TAIL_VOLATILITY_ROWS),
				);
				candidatePrefixLength = prefixLength;
				candidatePrefixAge = 0;
			}
		}
	}

	return {
		appendOnly,
		volatileCooldown,
		stablePrefixLength,
		candidatePrefixLength,
		candidatePrefixAge,
		rewriteFloor,
		// A clean-streaming block's body is committable up to the volatile-tail
		// holdback (the streaming edge is never offered, so its tolerated
		// rewrites can never touch committed rows); otherwise the settled head
		// still is — only the volatile tail stays deferred. Strict in-place
		// growth of the trailing row skips the holdback: its only mutable row
		// is the block's last, which cannot commit while it remains last.
		safeLength: appendOnly
			? trailingRowGrowth
				? current.length
				: Math.max(stablePrefixLength, current.length - TAIL_VOLATILITY_ROWS, 0)
			: stablePrefixLength,
	};
}

/**
 * Transcript container that renders every block's current content each frame
 * and reports the live-region seam (`NativeScrollbackLiveRegion`) that gates
 * the engine's append-only scrollback commits.
 *
 * The engine never rewrites committed history: rows above the seam that have
 * entered the tape keep whatever bytes they were committed with ("let the
 * history be"), while the visible window always repaints from each block's
 * latest render — a late tool result, a post-finalize error pin, or an expand
 * toggle is always reflected on screen. Blocks that are still mutating (an
 * unfinalized tool, a streaming assistant message) stay below the seam so
 * their rows do not enter history while they can still change; a streaming
 * block whose render grows append-only deepens the seam through its settled
 * head so a long reply's scrolled-off rows still reach scrollback mid-stream.
 *
 * Assembly is incremental: the returned array is persistent and mutated in
 * place. Each block's render is still called every frame, but a block whose
 * render returned the same array reference at an unchanged offset reuses its
 * previously assembled rows; the array is truncated and re-pushed only from
 * the first divergent block. The leading byte-identical row count is reported
 * through {@link RenderStablePrefix} so the engine can skip marker scanning,
 * line preparation, and the committed-prefix audit for those rows.
 */
export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion, RenderStablePrefix {
	// Bumped to retire every block's diff snapshot at once (theme change /
	// clear); a snapshot is only honored when its stored generation matches.
	#generation = 0;
	// Local line index where the current live region begins in the most recent
	// render. TUI commits rows to native scrollback only above this seam (or
	// the deeper commit-safe end below).
	#nativeScrollbackLiveRegionStart: number | undefined;
	// Local line index up to which the leading run of live blocks is safe to
	// commit. Finalized blocks contribute their full body; still-live blocks
	// contribute only while their render has been observed growing without
	// visibly rewriting a previously rendered interior row (escape placement
	// and pad drift are ignored). A rewrite suspends the block's contribution
	// until it re-earns append-only via VOLATILE_REARM_FRAMES clean frames;
	// the engine then backfills the stalled gap.
	#nativeScrollbackCommitSafeEnd: number | undefined;
	// Persistent assembled transcript rows. Rows before the stable floor are
	// byte-identical to the previous render; rows at/after it were re-pushed.
	#lines: string[] = [];
	#segments: BlockSegment[] = EMPTY_SEGMENTS;
	#renderWidth = -1;
	// Stable-prefix floor accumulated across renders since the last
	// getRenderStablePrefixRows() read (see RenderStablePrefix: reading
	// consumes the report and re-bases the baseline). Out-of-band renders
	// between engine frames lower it; they can never inflate it.
	#stableRowsFloor = 0;
	override invalidate(): void {
		// Theme/global invalidation: retire every diff snapshot so stale styling
		// is not diffed against the recolored render.
		this.#generation++;
		super.invalidate();
	}

	override clear(): void {
		this.#generation++;
		super.clear();
	}

	getRenderStablePrefixRows(): number {
		const value = Math.min(this.#stableRowsFloor, this.#lines.length);
		this.#stableRowsFloor = this.#lines.length;
		return value;
	}

	getNativeScrollbackLiveRegionStart(): number | undefined {
		return this.#nativeScrollbackLiveRegionStart;
	}

	getNativeScrollbackCommitSafeEnd(): number | undefined {
		return this.#nativeScrollbackCommitSafeEnd;
	}

	/**
	 * Whether `component` sits below a still-mutating block — i.e. inside the
	 * live region, where its rows cannot have been committed to native
	 * scrollback yet (commits are prefix-only and stop at the first
	 * still-live block). Callers that retract ephemeral blocks (IRC cards)
	 * must check this: removing a block whose rows may already be in history
	 * is an interior deletion of the committed prefix, which the engine can
	 * only repair by recommitting everything below it — duplication.
	 */
	isWithinLiveRegion(component: Component): boolean {
		const index = this.children.indexOf(component);
		if (index < 0) return false;
		for (let i = 0; i < index; i++) {
			if (!isBlockFinalized(this.children[i]!)) return true;
		}
		return false;
	}

	override render(width: number): readonly string[] {
		width = Math.max(1, width);
		this.#nativeScrollbackLiveRegionStart = undefined;
		this.#nativeScrollbackCommitSafeEnd = undefined;

		const count = this.children.length;

		// The live region spans from the earliest still-mutating block through the
		// bottom. A block that has not finalized must stay below the seam: out-of-
		// band inserts (TTSR/todo cards) can append a finalized block *below* a
		// tool that is still awaiting its result, and committing the tool there
		// would strand its history rows on the mid-stream preview the late result
		// never reaches.
		let liveStartIndex = count - 1;
		for (let i = 0; i < count; i++) {
			if (!isBlockFinalized(this.children[i]!)) {
				liveStartIndex = i;
				break;
			}
		}

		const lines = this.#lines;
		const previousSegments = this.#segments;
		const segments: BlockSegment[] = new Array(count);
		// Poisoned until the walk completes: a block render throwing mid-walk
		// leaves the persistent array half-rebuilt, and the next render must
		// not trust stale segments against it. Restored at the end.
		this.#segments = EMPTY_SEGMENTS;
		const stableFloorBefore = this.#stableRowsFloor;
		this.#stableRowsFloor = 0;
		// Stability requires the same width and, per segment, the same block at
		// the same offset returning the same array reference. The first
		// divergence truncates the persistent array there; everything after
		// re-pushes.
		let chainStable = this.#renderWidth === width;
		this.#renderWidth = width;
		// Entry-unstable (width change): the divergence truncation inside the
		// loop only fires on a stable→unstable transition, so reset the
		// persistent array here to keep the `!chainStable ⇒ lines.length === row`
		// invariant — otherwise re-pushed rows land after the stale frame.
		if (!chainStable) lines.length = 0;

		// Tracks whether we are still inside the leading run of commit-safe live
		// blocks. The first still-live volatile block closes it, but rendering
		// continues so lower blocks remain visible.
		let commitSafeOpen = true;
		// The live-region start is recorded at the first visible row at/after
		// liveStartIndex; empty leading blocks (or a separator) must not claim it
		// early.
		let liveRecorded = false;
		// Frame row cursor: rows emitted (reused or pushed) so far.
		let row = 0;
		let stableRows = 0;
		for (let i = 0; i < count; i++) {
			const child = this.children[i]! as Component & SnapshotCarrier;

			// This child's contribution: its current render with plain-blank
			// top/bottom edges stripped (the container owns inter-block gaps).
			// Always the latest content — committed history keeps whatever bytes
			// it was written with, but the window must reflect the present state
			// (late tool results, post-finalize re-layouts, expand toggles).
			// A block whose render returned the same array reference reuses the
			// previously stripped contribution (same ref ⇒ identical rows).
			const previousSnapshot = child[kSnapshot];
			const raw = child.render(width);
			const previous = previousSegments[i];
			const reusable =
				previous !== undefined &&
				previous.component === child &&
				previous.rawRef === raw &&
				previous.width === width;
			const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
			const finalized = isBlockFinalized(child);
			let liveCommitState: LiveCommitState | undefined;
			if (i >= liveStartIndex && !finalized) {
				liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
			}
			// Cache the latest contribution as the next frame's diff input.
			child[kSnapshot] = {
				width,
				lines: contribution,
				generation: this.#generation,
				appendOnly: liveCommitState?.appendOnly ?? false,
				volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
				stablePrefixLength: liveCommitState?.stablePrefixLength ?? 0,
				candidatePrefixLength: liveCommitState?.candidatePrefixLength ?? 0,
				candidatePrefixAge: liveCommitState?.candidatePrefixAge ?? 0,
				rewriteFloor: liveCommitState?.rewriteFloor ?? Number.POSITIVE_INFINITY,
			};

			// Empty (or stripped-to-nothing) children contribute nothing and never
			// affect spacing or the live-region offsets. An empty still-live child
			// still closes the commit-safe run: if it later gains rows, it pushes
			// everything below it.
			if (contribution.length === 0) {
				if (i >= liveStartIndex && commitSafeOpen && !finalized) commitSafeOpen = false;
				if (chainStable && !(reusable && previous.rowCount === 0 && previous.startRow === row)) {
					chainStable = false;
					lines.length = row;
				}
				if (chainStable) stableRows = row;
				segments[i] = { component: child, rawRef: raw, contribution, width, startRow: row, rowCount: 0, sep: 0 };
				continue;
			}

			// Every block is separated from preceding visible content by exactly one
			// blank row — skipped when it opens the transcript or the prior row is
			// already a plain blank (a fragment's own trailing pad), never doubling.
			// `lines[row - 1]` is valid in both modes: reused rows are still present
			// in the persistent array, re-pushed rows were just written.
			const sep = row > 0 && !isPlainBlank(lines[row - 1]!) ? 1 : 0;

			// The separator before the first live block stays in the committed
			// prefix (it is deterministic once the prior block's body is settled),
			// so the live region begins at the block's first content row.
			if (!liveRecorded && i >= liveStartIndex) {
				this.#nativeScrollbackLiveRegionStart = row + sep;
				liveRecorded = true;
			}

			const rowCount = sep + contribution.length;
			const stable = chainStable && reusable && previous.startRow === row && previous.sep === sep;
			if (stable) {
				stableRows = row + rowCount;
			} else {
				if (chainStable) {
					chainStable = false;
					lines.length = row;
				}
				if (sep) lines.push("");
				for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
			}

			const blockStart = row + sep;
			if (i >= liveStartIndex && commitSafeOpen) {
				const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
				if (safeLength > 0) {
					this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength;
				}
				// A finalized, fully safe block may let the contiguous safe run extend
				// into blocks rendered below it. A still-live block keeps pushing lower
				// rows around as it grows, so the run closes there.
				if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
			}

			segments[i] = { component: child, rawRef: raw, contribution, width, startRow: row, rowCount, sep };
			row += rowCount;
		}
		// Trailing shrink: blocks removed from the tail leave stale rows behind
		// when every surviving segment was reused.
		if (lines.length !== row) lines.length = row;
		this.#segments = segments;
		this.#stableRowsFloor = Math.min(stableFloorBefore, stableRows, row);
		return lines;
	}
}

/**
 * Groups a run of sibling rows (an IRC card's header + body, a file-mention
 * list, a bordered command/version panel) into a single transcript child so the
 * container spaces it as one block — one blank line above, none injected between
 * its rows. Without this wrapper the rows would be top-level children and the
 * container would put a blank line between each (and inside any border box).
 * It is a plain {@link Container}; the named subclass documents intent and makes
 * every manual block grouping greppable.
 */
export class TranscriptBlock extends Container {}
