import { ansiCodesToString, diffAnsiCodes, } from '@alcalzone/ansi-tokenize';
import { logForDebugging } from '../utils/debug.js';
import { cellAt, charInCellAt, diffEach, isEmptyCellAt, shiftRows, visibleCellAtIndex, } from './screen.js';
import { CURSOR_HOME, cursorPosition, eraseToEndOfLine, scrollDown as csiScrollDown, scrollUp as csiScrollUp, RESET_SCROLL_REGION, setScrollRegion, } from './termio/csi.js';
import { LINK_END, link as oscLink } from './termio/osc.js';
const CARRIAGE_RETURN = { type: 'carriageReturn' };
const NEWLINE = { type: 'stdout', content: '\n' };
// Strip C0 control characters from a line that is about to be committed to
// terminal scrollback. A committed line is permanent and is positioned by the
// commit sequence (CUP + per-line \x1b[K + \n); any bare control char inside it
// fights that positioning. A lone \r is the worst offender: it resets the
// cursor to column 0 mid-row, so the following \x1b[K erases the line we just
// wrote (→ a blank/missing row) or the next line's content overwrites this row's
// head (→ the "row A head replaced by row B" smear, e.g. `[stdout]13ms)`). Real
// tool output routinely carries \r (CRLF endings, progress bars, CI logs), and
// nothing upstream removes it. TAB (\x09) is preserved (handled by tab
// expansion) and ESC (\x1b) is preserved (it carries the SGR/style ANSI we
// intentionally emit). \n never reaches here — callers split on it first.
const COMMIT_CONTROL_CHARS = /[\x00-\x08\x0b-\x1a\x1c-\x1f\x7f]/g;
export function sanitizeCommitLine(line) {
    return line.replace(COMMIT_CONTROL_CHARS, '');
}
export class LogUpdate {
    options;
    state;
    constructor(options) {
        this.options = options;
        this.state = {
            previousOutput: '',
        };
    }
    renderPreviousOutput_DEPRECATED(prevFrame) {
        if (!this.options.isTTY) {
            // Non-TTY output is no longer supported (string output was removed)
            return [NEWLINE];
        }
        return this.getRenderOpsForDone(prevFrame);
    }
    // Called when process resumes from suspension (SIGCONT) to prevent clobbering terminal content
    reset() {
        this.state.previousOutput = '';
    }
    // Framecount since the last full-reset. Used as a periodic-scrub counter:
    // cell-diff can produce correct cursor-forward skips whose outputs DON'T
    // match what the terminal actually shows when virtual-screen state drifts
    // from physical-terminal state (scroll region manipulation, static commits,
    // compaction-driven React tree rebuilds, etc.). Drift manifests as the
    // "row N tail + row N+1 head collide on one visible row" smear (see
    // dump analysis in commit comments). Forcing a fullReset every N frames
    // scrubs any accumulated drift without being so frequent that flicker
    // becomes noticeable.
    framesSinceFullReset = 0;
    static SCRUB_EVERY_N_FRAMES = 240;
    renderFullFrame(frame) {
        const { screen } = frame;
        const lines = [];
        let currentStyles = [];
        let currentHyperlink = undefined;
        for (let y = 0; y < screen.height; y++) {
            let line = '';
            for (let x = 0; x < screen.width; x++) {
                const cell = cellAt(screen, x, y);
                if (cell && cell.width !== 2 /* CellWidth.SpacerTail */) {
                    // Handle hyperlink transitions
                    if (cell.hyperlink !== currentHyperlink) {
                        if (currentHyperlink !== undefined) {
                            line += LINK_END;
                        }
                        if (cell.hyperlink !== undefined) {
                            line += oscLink(cell.hyperlink);
                        }
                        currentHyperlink = cell.hyperlink;
                    }
                    const cellStyles = this.options.stylePool.get(cell.styleId);
                    const styleDiff = diffAnsiCodes(currentStyles, cellStyles);
                    if (styleDiff.length > 0) {
                        line += ansiCodesToString(styleDiff);
                        currentStyles = cellStyles;
                    }
                    line += cell.char;
                }
            }
            // Close any open hyperlink before resetting styles
            if (currentHyperlink !== undefined) {
                line += LINK_END;
                currentHyperlink = undefined;
            }
            // Reset styles at end of line so trimEnd doesn't leave dangling codes
            const resetCodes = diffAnsiCodes(currentStyles, []);
            if (resetCodes.length > 0) {
                line += ansiCodesToString(resetCodes);
                currentStyles = [];
            }
            lines.push(line.trimEnd());
        }
        if (lines.length === 0) {
            return [];
        }
        return [{ type: 'stdout', content: lines.join('\n') }];
    }
    getRenderOpsForDone(prev) {
        this.state.previousOutput = '';
        if (!prev.cursor.visible) {
            return [{ type: 'cursorShow' }];
        }
        return [];
    }
    render(prev, next, altScreen = false, decstbmSafe = true, forceFullRepaint = false) {
        if (!this.options.isTTY) {
            return this.renderFullFrame(next);
        }
        const startTime = performance.now();
        const stylePool = this.options.stylePool;
        // ── Watermark-v2: static commit ─────────────────────────────────
        // If this frame carries a non-empty static-commit payload, emit the
        // commit ANSI first: RESET scroll region (defensive), CUP to (1,1),
        // write the commit text, CUP to bottom row, emit rowCount \n to
        // scroll pre-commit viewport into terminal scrollback. Then force a
        // full-reset for the dynamic region (the post-commit viewport's
        // previous cell-by-cell diff state is invalidated).
        //
        // rowCount > 0 guard: an empty commit (no text, no rows) would
        // pay the full-reset flicker cost for no visible benefit. Skip
        // to the incremental-diff path below in that case.
        //
        // \x1b[r reset-scroll-region is defensive: the CUP-to-bottom + \n
        // trick only scrolls into scrollback when the DECSTBM region is
        // the default (full screen). Any upstream code path that set a
        // sub-screen scroll region would otherwise make the commit lines
        // loop inside that region instead of reaching scrollback. This
        // guarantees the commit works regardless of prior scroll-region
        // state and is cheap (2 bytes) on well-behaved terminals.
        //
        // INVARIANT: these patches ride on the SAME return value as any
        // dynamic-region patches, so writeDiffToTerminal writes them in
        // one buffered stdout.write. No side-channel writes allowed.
        if (next.staticCommit && next.staticCommit.rowCount > 0) {
            // CUP(1,1) + commit text writes into the TOP rows of the
            // currently-painted dynamic region. The write only overwrites
            // the columns it physically emits into — any stale characters
            // past the commit text's width on each target row are left
            // untouched. When the subsequent \n burst scrolls those rows
            // into terminal scrollback, the stale tail is baked in
            // permanently as "<commit text><old row tail>" char-level
            // contamination. Real-machine QA reproduced this as the
            // "loopsmpted the model to produce…" smear: short summary
            // lines scrolled into scrollback with tails of the previous
            // dynamic rows still visible behind them.
            //
            // Fix: terminate each commit line with \x1b[K (erase-in-line
            // to end of row) BEFORE the embedded \n. The ANSI still goes
            // through the same writeDiffToTerminal buffered write, so the
            // one-invariant (per-frame single stdout write) is preserved.
            // \x1b[K is a VT100-standard sequence supported universally
            // across Terminal.app / iTerm2 / tmux / Ghostty / Alacritty.
            // Pre-wrapped content arrives with exactly one logical line per
            // physical terminal row (see Ink.onRender's wrapAnsi pass). The
            // DECAWM (auto-wrap) disable around the write is belt-and-braces:
            // it guarantees a line that happens to fill viewport.width exactly
            // doesn't push the cursor into the "pending wrap" state where a
            // trailing \x1b[K or \n misbehaves across terminal emulators
            // (Terminal.app, iTerm2, tmux and Ghostty each interpret the
            // deferred-wrap edge slightly differently).
            // Sanitize each committed line: bare C0 controls (esp. \r from CRLF tool
            // output / progress bars) would reset the cursor mid-row and collide with
            // the per-line \x1b[K / next line, blanking or head-overwriting scrollback
            // rows. Control chars are zero-width, so this does not change rowCount.
            const commitLines = next.staticCommit.text.split('\n').map(sanitizeCommitLine);
            const paddedCommit = commitLines.join('\x1b[K\n') + '\x1b[K';
            // Bulk LF burst clamp:
            //   When rowCount > viewport.height, paddedCommit's embedded \n's already
            //   produced (rowCount - viewport.height + 1) natural scrolls during the
            //   write (each LF at the bottom row pushes one commit line into
            //   scrollback). The bottom-row LF burst here only needs to push the
            //   REMAINING commit lines that are still visible in the viewport — at
            //   most viewport.height - 1 rows. Emitting the full `rowCount` LFs
            //   over-scrolls by (rowCount - viewport.height + 1) blank rows, which
            //   land in terminal scrollback as a long trailing whitespace strip
            //   (visible to users as "why is there 60+ blank lines after the table?").
            //
            //   For rowCount <= viewport.height there's no natural scroll yet;
            //   we still need rowCount LFs to push everything into scrollback.
            //   Math.min handles both cases.
            const bulkScrollCount = Math.min(next.staticCommit.rowCount, Math.max(0, next.viewport.height - 1));
            const commitPatches = [
                { type: 'stdout', content: `\x1b[r` }, // Reset DECSTBM scroll region (defensive)
                { type: 'stdout', content: `\x1b[?7l` }, // Disable auto-wrap for the commit write
                { type: 'stdout', content: `\x1b[1;1H` }, // CUP to absolute top-left (1,1)
                { type: 'stdout', content: paddedCommit }, // commit text, each line \x1b[K-terminated
                { type: 'stdout', content: `\x1b[?7h` }, // Re-enable auto-wrap before scroll
                { type: 'stdout', content: `\n` }, // terminator below last committed row
                { type: 'stdout', content: `\x1b[${next.viewport.height};1H` }, // CUP to absolute bottom row
                { type: 'stdout', content: '\n'.repeat(bulkScrollCount) }, // scroll pre-commit viewport down (clamped)
            ];
            // Repaint the visible viewport from scratch via safeFullRepaint —
            // emits CUP-absolute + content + EL per visible row, no \x1b[2J, and
            // no repaint-owned scrollback writes. Used here for
            // BOTH cell-diff and safe_repaint modes: every Static commit triggers
            // one repaint; the old fullResetSequence path would emit \x1b[2J which
            // on Terminal.app / iTerm2 pushes the visible viewport into scrollback
            // alongside the intentionally-committed text (every line appears twice
            // — observed in cell-diff mode after the e4a5a54 revert).
            //
            // Note: the staticCommit path runs at most once per Static commit
            // (typically a few times per second during streaming), not per frame
            // like the safe_repaint default did. The 4-layer cost the same code
            // accumulated when called per-frame doesn't apply here — there's no
            // 60Hz amplification of per-paint quirks.
            const resetPatches = safeFullRepaint(next, stylePool, 'clear');
            // Kill any incremental-diff back-buffer state so the next frame
            // doesn't try to diff against a pre-commit screen.
            this.state.previousOutput = '';
            this.framesSinceFullReset = 0;
            return [...commitPatches, ...resetPatches];
        }
        if (forceFullRepaint) {
            // safe_repaint runs every frame on the main (non-alt-screen) REPL.
            // Use the in-place rewrite path: absolute CUP per row + cell content +
            // EL terminator. NO `\x1b[2J` (which on Terminal.app/iTerm2 pushes the
            // visible viewport into scrollback before erasing) and NO trailing LF
            // (which scrolls the viewport when frame.screen.height >= viewport.height,
            // pushing the top row into scrollback every frame). The previous
            // fullResetSequence path leaked one viewport-snapshot into scrollback per
            // frame, which a 60Hz spinner accumulated into hundreds of duplicates of
            // every line that lived on-screen long enough.
            this.state.previousOutput = '';
            this.framesSinceFullReset = 0;
            return safeFullRepaint(next, stylePool, 'safe_repaint');
        }
        // Periodic drift scrub (see SCRUB_EVERY_N_FRAMES comment above).
        this.framesSinceFullReset += 1;
        if (this.framesSinceFullReset >= LogUpdate.SCRUB_EVERY_N_FRAMES) {
            this.framesSinceFullReset = 0;
            this.state.previousOutput = '';
            return safeFullRepaint(next, stylePool, 'periodic_scrub');
        }
        // Since we assume the cursor is at the bottom on the screen, we only need
        // to clear when the viewport gets shorter (i.e. the cursor position drifts)
        // or when it gets thinner (and text wraps). We _could_ figure out how to
        // not reset here but that would involve predicting the current layout
        // _after_ the viewport change which means calcuating text wrapping.
        // Resizing is a rare enough event that it's not practically a big issue.
        if (next.viewport.height < prev.viewport.height ||
            (prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width)) {
            this.framesSinceFullReset = 0;
            return safeFullRepaint(next, stylePool, 'resize');
        }
        // DECSTBM scroll optimization: when a ScrollBox's scrollTop changed,
        // shift content with a hardware scroll (CSI top;bot r + CSI n S/T)
        // instead of rewriting the whole scroll region. The shiftRows on
        // prev.screen simulates the shift so the diff loop below naturally
        // finds only the rows that scrolled IN as diffs. prev.screen is
        // about to become backFrame (reused next render) so mutation is safe.
        // CURSOR_HOME after RESET_SCROLL_REGION is defensive — DECSTBM reset
        // homes cursor per spec but terminal implementations vary.
        //
        // decstbmSafe: caller passes false when the DECSTBM→diff sequence
        // can't be made atomic (no DEC 2026 / BSU/ESU). Without atomicity the
        // outer terminal renders the intermediate state — region scrolled,
        // edge rows not yet painted — a visible vertical jump on every frame
        // where scrollTop moves. Falling through to the diff loop writes all
        // shifted rows: more bytes, no intermediate state. next.screen from
        // render-node-to-output's blit+shift is correct either way.
        let scrollPatch = [];
        if (altScreen && next.scrollHint && decstbmSafe) {
            const { top, bottom, delta } = next.scrollHint;
            if (top >= 0 &&
                bottom < prev.screen.height &&
                bottom < next.screen.height) {
                shiftRows(prev.screen, top, bottom, delta);
                scrollPatch = [
                    {
                        type: 'stdout',
                        content: setScrollRegion(top + 1, bottom + 1) +
                            (delta > 0 ? csiScrollUp(delta) : csiScrollDown(-delta)) +
                            RESET_SCROLL_REGION +
                            CURSOR_HOME,
                    },
                ];
            }
        }
        // We have to use purely relative operations to manipulate the cursor since
        // we don't know its starting point.
        //
        // When content height >= viewport height AND cursor is at the bottom,
        // the cursor restore at the end of the previous frame caused terminal scroll.
        // viewportY tells us how many rows are in scrollback from content overflow.
        // Additionally, the cursor-restore scroll pushes 1 more row into scrollback.
        // We need fullReset if any changes are to rows that are now in scrollback.
        //
        // This early full-reset check only applies in "steady state" (not growing).
        // For growing, the viewportY calculation below (with cursorRestoreScroll)
        // catches unreachable scrollback rows in the diff loop instead.
        const cursorAtBottom = prev.cursor.y >= prev.screen.height;
        const isGrowing = next.screen.height > prev.screen.height;
        // When content fills the viewport exactly (height == viewport) and the
        // cursor is at the bottom, the cursor-restore LF at the end of the
        // previous frame scrolled 1 row into scrollback. Use >= to catch this.
        const prevHadScrollback = cursorAtBottom && prev.screen.height >= prev.viewport.height;
        const isShrinking = next.screen.height < prev.screen.height;
        const nextFitsViewport = next.screen.height <= prev.viewport.height;
        // When shrinking from above-viewport to at-or-below-viewport, content that
        // was in scrollback should now be visible. Terminal clear operations can't
        // bring scrollback content into view, so we need a full reset.
        // Use <= (not <) because even when next height equals viewport height, the
        // scrollback depth from the previous render differs from a fresh render.
        if (prevHadScrollback && nextFitsViewport && isShrinking) {
            logForDebugging(`Full reset (shrink->below): prevHeight=${prev.screen.height}, nextHeight=${next.screen.height}, viewport=${prev.viewport.height}`);
            return safeFullRepaint(next, stylePool, 'offscreen');
        }
        if (prev.screen.height >= prev.viewport.height &&
            prev.screen.height > 0 &&
            cursorAtBottom &&
            !isGrowing) {
            // viewportY = rows in scrollback from content overflow
            // +1 for the row pushed by cursor-restore scroll
            const viewportY = prev.screen.height - prev.viewport.height;
            const scrollbackRows = viewportY + 1;
            let scrollbackChangeY = -1;
            diffEach(prev.screen, next.screen, (_x, y) => {
                if (y < scrollbackRows) {
                    scrollbackChangeY = y;
                    return true; // early exit
                }
            });
            if (scrollbackChangeY >= 0) {
                const prevLine = readLine(prev.screen, scrollbackChangeY);
                const nextLine = readLine(next.screen, scrollbackChangeY);
                return safeFullRepaint(next, stylePool, 'offscreen', {
                    triggerY: scrollbackChangeY,
                    prevLine,
                    nextLine,
                });
            }
        }
        const screen = new VirtualScreen(prev.cursor, next.viewport.width);
        // Treat empty screen as height 1 to avoid spurious adjustments on first render
        const heightDelta = Math.max(next.screen.height, 1) - Math.max(prev.screen.height, 1);
        const shrinking = heightDelta < 0;
        const growing = heightDelta > 0;
        // Handle shrinking: clear lines from the bottom
        if (shrinking) {
            const linesToClear = prev.screen.height - next.screen.height;
            // eraseLines only works within the viewport - it can't clear scrollback.
            // If we need to clear more lines than fit in the viewport, some are in
            // scrollback, so we need a full reset.
            if (linesToClear > prev.viewport.height) {
                return safeFullRepaint(next, this.options.stylePool, 'offscreen');
            }
            // clear(N) moves cursor UP by N-1 lines and to column 0
            // This puts us at line prev.screen.height - N = next.screen.height
            // But we want to be at next.screen.height - 1 (bottom of new screen)
            screen.txn(prev => [
                [
                    { type: 'clear', count: linesToClear },
                    { type: 'cursorMove', x: 0, y: -1 },
                ],
                { dx: -prev.x, dy: -linesToClear },
            ]);
        }
        // viewportY = number of rows in scrollback (not visible on terminal).
        // For shrinking: use max(prev, next) because terminal clears don't scroll.
        // For growing: use prev state because new rows haven't scrolled old ones yet.
        // When prevHadScrollback, add 1 for the cursor-restore LF that scrolled
        // an additional row out of view at the end of the previous frame. Without
        // this, the diff loop treats that row as reachable — but the cursor clamps
        // at viewport top, causing writes to land 1 row off and garbling the output.
        const cursorRestoreScroll = prevHadScrollback ? 1 : 0;
        const viewportY = growing
            ? Math.max(0, prev.screen.height - prev.viewport.height + cursorRestoreScroll)
            : Math.max(prev.screen.height, next.screen.height) -
                next.viewport.height +
                cursorRestoreScroll;
        let currentStyleId = stylePool.none;
        let currentHyperlink = undefined;
        // First pass: render changes to existing rows (rows < prev.screen.height)
        let needsFullReset = false;
        let resetTriggerY = -1;
        diffEach(prev.screen, next.screen, (x, y, removed, added) => {
            // Skip new rows - we'll render them directly after
            if (growing && y >= prev.screen.height) {
                return;
            }
            // Skip spacers during rendering because the terminal will automatically
            // advance 2 columns when we write the wide character itself.
            // SpacerTail: Second cell of a wide character
            // SpacerHead: Marks line-end position where wide char wraps to next line
            if (added &&
                (added.width === 2 /* CellWidth.SpacerTail */ ||
                    added.width === 3 /* CellWidth.SpacerHead */)) {
                return;
            }
            if (removed &&
                (removed.width === 2 /* CellWidth.SpacerTail */ ||
                    removed.width === 3 /* CellWidth.SpacerHead */) &&
                !added) {
                return;
            }
            // Skip empty cells that don't need to overwrite existing content.
            // This prevents writing trailing spaces that would cause unnecessary
            // line wrapping at the edge of the screen.
            // Uses isEmptyCellAt to check if both packed words are zero (empty cell).
            if (added && isEmptyCellAt(next.screen, x, y) && !removed) {
                return;
            }
            // If the cell outside the viewport range has changed, we need to reset
            // because we can't move the cursor there to draw.
            if (y < viewportY) {
                needsFullReset = true;
                resetTriggerY = y;
                return true; // early exit
            }
            moveCursorTo(screen, x, y);
            if (added) {
                const targetHyperlink = added.hyperlink;
                currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, targetHyperlink);
                const styleStr = stylePool.transition(currentStyleId, added.styleId);
                if (writeCellWithStyleStr(screen, added, styleStr)) {
                    currentStyleId = added.styleId;
                }
            }
            else if (removed) {
                // Cell was removed - clear it with a space
                // (This handles shrinking content)
                // Reset any active styles/hyperlinks first to avoid leaking into cleared cells
                const styleIdToReset = currentStyleId;
                const hyperlinkToReset = currentHyperlink;
                currentStyleId = stylePool.none;
                currentHyperlink = undefined;
                screen.txn(() => {
                    const patches = [];
                    transitionStyle(patches, stylePool, styleIdToReset, stylePool.none);
                    transitionHyperlink(patches, hyperlinkToReset, undefined);
                    patches.push({ type: 'stdout', content: ' ' });
                    return [patches, { dx: 1, dy: 0 }];
                });
            }
        });
        if (needsFullReset) {
            return safeFullRepaint(next, stylePool, 'offscreen', {
                triggerY: resetTriggerY,
                prevLine: readLine(prev.screen, resetTriggerY),
                nextLine: readLine(next.screen, resetTriggerY),
            });
        }
        // Reset styles before rendering new rows (they'll set their own styles)
        currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none);
        currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined);
        // Handle growth: render new rows directly (they naturally scroll the terminal)
        if (growing) {
            renderFrameSlice(screen, next, prev.screen.height, next.screen.height, stylePool);
        }
        // Restore cursor. Skipped in alt-screen: the cursor is hidden, its
        // position only matters as the starting point for the NEXT frame's
        // relative moves, and in alt-screen the next frame always begins with
        // CSI H (see ink.tsx onRender) which resets to (0,0) regardless. This
        // saves a CR + cursorMove round-trip (~6-10 bytes) every frame.
        //
        // Main screen: if cursor needs to be past the last line of content
        // (typical: cursor.y = screen.height), emit \n to create that line
        // since cursor movement can't create new lines.
        if (altScreen) {
            // no-op; next frame's CSI H anchors cursor
        }
        else if (next.cursor.y >= next.screen.height) {
            // Move to column 0 of current line, then emit newlines to reach target row
            screen.txn(prev => {
                const rowsToCreate = next.cursor.y - prev.y;
                if (rowsToCreate > 0) {
                    // Use CR to resolve pending wrap (if any) without advancing
                    // to the next line, then LF to create each new row.
                    const patches = new Array(1 + rowsToCreate);
                    patches[0] = CARRIAGE_RETURN;
                    for (let i = 0; i < rowsToCreate; i++) {
                        patches[1 + i] = NEWLINE;
                    }
                    return [patches, { dx: -prev.x, dy: rowsToCreate }];
                }
                // At or past target row - need to move cursor to correct position
                const dy = next.cursor.y - prev.y;
                if (dy !== 0 || prev.x !== next.cursor.x) {
                    // Use CR to clear pending wrap (if any), then cursor move
                    const patches = [CARRIAGE_RETURN];
                    patches.push({ type: 'cursorMove', x: next.cursor.x, y: dy });
                    return [patches, { dx: next.cursor.x - prev.x, dy }];
                }
                return [[], { dx: 0, dy: 0 }];
            });
        }
        else {
            moveCursorTo(screen, next.cursor.x, next.cursor.y);
        }
        const elapsed = performance.now() - startTime;
        if (elapsed > 50) {
            const damage = next.screen.damage;
            const damageInfo = damage
                ? `${damage.width}x${damage.height} at (${damage.x},${damage.y})`
                : 'none';
            logForDebugging(`Slow render: ${elapsed.toFixed(1)}ms, screen: ${next.screen.height}x${next.screen.width}, damage: ${damageInfo}, changes: ${screen.diff.length}`);
        }
        return scrollPatch.length > 0
            ? [...scrollPatch, ...screen.diff]
            : screen.diff;
    }
}
function transitionHyperlink(diff, current, target) {
    if (current !== target) {
        diff.push({ type: 'hyperlink', uri: target ?? '' });
        return target;
    }
    return current;
}
function transitionStyle(diff, stylePool, currentId, targetId) {
    const str = stylePool.transition(currentId, targetId);
    if (str.length > 0) {
        diff.push({ type: 'styleStr', str });
    }
    return targetId;
}
function readLine(screen, y) {
    let line = '';
    for (let x = 0; x < screen.width; x++) {
        line += charInCellAt(screen, x, y) ?? ' ';
    }
    return line.trimEnd();
}
/**
 * Unified main-screen full-repaint entry. EVERY path that needs to repaint
 * the whole visible viewport — staticCommit reset, periodic_scrub, resize,
 * offscreen drift detection, forceRedraw, safe_repaint mode — goes through
 * this function.
 *
 * Returned Diff is composed as:
 *   1. flickerMarker — telemetry-only; carries reason + debug for onRender's
 *      flickers[] tracking. Emits ZERO bytes to terminal.
 *   2. inplaceFullRepaintSequence — actual repaint ANSI: CUP + EL + cell
 *      content for the visible viewport rows, CR+LF advance inside the
 *      viewport only, DECAWM bracket. No \x1b[2J. No repaint-owned
 *      scrollback writes. Smear-resistant.
 *
 * INVARIANT: This is the ONLY way main-screen rendering can produce a full
 * repaint. Anything else emitting \x1b[2J on main screen is a bug —
 * tests/ink/no-ed2-on-main-screen.test.ts locks that contract.
 *
 * Note: `clearTerminal` patch type was removed when this entry replaced the
 * legacy fullResetSequence_CAUSES_FLICKER. The patch was a footgun: callers
 * easily slipped a literal \x1b[2J into the main-screen byte stream via
 * writeDiffToTerminal's translation. Now that emission is no-op'd, no
 * accidental ED 2 can leak through the patch system.
 */
export function safeFullRepaint(frame, stylePool, reason, debug) {
    return [
        { type: 'flickerMarker', reason, debug },
        ...inplaceFullRepaintSequence(frame, stylePool),
    ];
}
/**
 * Build an in-place full-repaint Diff that rewrites the current visible
 * viewport rows without mutating terminal scrollback.
 *
 * Contract:
 *   - Emits NO `\x1b[2J` ERASE_SCREEN. Terminal.app and iTerm2 (and tmux on
 *     mediated panes) interpret 2J as "scroll the visible viewport into
 *     scrollback, then blank the visible area" rather than the in-place
 *     erase the xterm spec describes. Doing that 60×/sec would accumulate a
 *     viewport-snapshot per frame in scrollback (the original P0).
 *   - Paints only the bottom `viewport.height` rows when `frame.screen`
 *     overflows. A repaint is allowed to repair the physical viewport; it is
 *     NOT allowed to create terminal scrollback entries. Scrollback ownership
 *     belongs to Static commits (transcript rows) and explicit user actions.
 *     Letting repaint overflow rows "naturally" scroll into terminal
 *     scrollback created a duplicate-copy class: streamed tail content first
 *     entered scrollback as repaint side effect, then the same rows were
 *     committed by Static when the assistant turn completed.
 *   - The final row gets NO trailing CR+LF — without that guard, the last
 *     row's LF would scroll one row too many, pushing a visible row into
 *     scrollback.
 *   - Each row is bracketed with `\x1b[K` (erase-in-line) before AND after
 *     the cell loop. Pre-EL kills leading empty cells the cell-loop's
 *     skip-empty optimization would otherwise leave with stale content
 *     ("⢦⣤3.架构分层" smear). Post-EL kills trailing residue past the
 *     new shorter content ("contextnts/Spinner" smear).
 *   - Wrapped in DECAWM disable (`\x1b[?7l` / `\x1b[?7h`) so a row that
 *     fills exactly viewport.width can't enter pending-wrap state where a
 *     subsequent CUP/EL behaves divergently across terminals.
 *
 * After the loop, any rows below `frame.screen.height` (when the frame is
 * shorter than the viewport) are explicitly EL'd at absolute CUP — those
 * positions never get walked by the LF-advance loop and would otherwise
 * keep prior-frame content.
 */
function inplaceFullRepaintSequence(frame, stylePool) {
    // VirtualScreen tracks the physical viewport row. Repaints must stay
    // viewport-bounded; scrollback is owned by Static commits, not by repaint.
    const screen = new VirtualScreen({ x: 0, y: 0 }, frame.viewport.width);
    // DECAWM off — see header comment.
    screen.diff.push({ type: 'stdout', content: '\x1b[?7l' });
    // Anchor the paint at (1, 1) so subsequent LF advances have predictable
    // positioning. We never advance beyond the visible viewport.
    screen.diff.push({ type: 'stdout', content: cursorPosition(1, 1) });
    screen.cursor.x = 0;
    screen.cursor.y = 0;
    let currentStyleId = stylePool.none;
    let currentHyperlink = undefined;
    const { width: screenWidth, cells, charPool, hyperlinkPool } = frame.screen;
    const rowsToWalk = Math.min(frame.screen.height, frame.viewport.height);
    const sourceRowStart = Math.max(0, frame.screen.height - frame.viewport.height);
    for (let y = 0; y < rowsToWalk; y++) {
        // Pre-EL: clear the row at the current cursor position before any cell
        // writes. Catches both leading empty cells (skipped by visibleCellAtIndex)
        // and any prior-frame residue at this physical row.
        screen.diff.push({ type: 'stdout', content: eraseToEndOfLine() });
        let lastRenderedStyleId = -1;
        let index = (sourceRowStart + y) * screenWidth;
        for (let x = 0; x < screenWidth; x++, index++) {
            const cell = visibleCellAtIndex(cells, charPool, hyperlinkPool, index, lastRenderedStyleId);
            if (!cell)
                continue;
            // Paste-friendly cursor advance: instead of emitting cursor-forward
            // (\x1b[NC) to skip over empty cells before the next content cell,
            // write literal ASCII spaces. Visually identical (the row was pre-EL'd
            // so the cells are blank), but copy-paste / screenshot OCR / "share
            // this terminal output" flows preserve the spaces verbatim. Without
            // this, the cursor-forward escape gets stripped on copy and adjacent
            // tokens collapse into "5.显著亮点" / "前端对外暴露Anthropic" etc —
            // visible terminal display is fine, but every paste of a long answer
            // is unreadable. Same-row only: row advances still go via CR+LF
            // (NEWLINE patch type) which paste already handles.
            const dxSkip = x - screen.cursor.x;
            if (dxSkip > 0 && screen.cursor.y === y) {
                screen.diff.push({ type: 'stdout', content: ' '.repeat(dxSkip) });
                screen.cursor.x = x;
            }
            else {
                moveCursorTo(screen, x, screen.cursor.y);
            }
            currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, cell.hyperlink);
            const styleStr = stylePool.transition(currentStyleId, cell.styleId);
            if (writeCellWithStyleStr(screen, cell, styleStr)) {
                currentStyleId = cell.styleId;
                lastRenderedStyleId = cell.styleId;
            }
        }
        // Reset styles + close any open hyperlink BEFORE EL/advance so any
        // cleared tail isn't painted with a leftover background color attribute.
        currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none);
        currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined);
        // Post-EL: clear from cursor to end-of-row, wiping any prior residue
        // past the new content's last cell.
        screen.diff.push({ type: 'stdout', content: eraseToEndOfLine() });
        // CR+LF advance to next row — UNLESS this is the last row. Without the
        // guard, the trailing LF on the last row would scroll one extra time
        // and push a visible row into scrollback.
        if (y < rowsToWalk - 1) {
            screen.txn(prev => [
                [CARRIAGE_RETURN, NEWLINE],
                { dx: -prev.x, dy: 1 },
            ]);
        }
    }
    // Frame shorter than viewport — explicitly clear the trailing rows.
    // The CR+LF loop above only advanced through `rowsToWalk` rows; any
    // viewport rows below that still hold prior-frame content.
    if (rowsToWalk < frame.viewport.height) {
        for (let row = rowsToWalk; row < frame.viewport.height; row++) {
            screen.diff.push({
                type: 'stdout',
                content: cursorPosition(row + 1, 1),
            });
            screen.diff.push({ type: 'stdout', content: eraseToEndOfLine() });
        }
    }
    // Re-enable DECAWM for any subsequent terminal interaction outside this paint.
    screen.diff.push({ type: 'stdout', content: '\x1b[?7h' });
    // Land the cursor where the frame says. When the frame overflowed, only
    // the bottom `viewport.height` rows are visible — the cursor's logical y
    // maps to viewport row `cursor.y - overflow`. Clamp to viewport bounds.
    const overflow = Math.max(0, frame.screen.height - frame.viewport.height);
    const targetY = Math.max(0, Math.min(frame.viewport.height - 1, frame.cursor.y - overflow));
    const targetX = Math.max(0, Math.min(frame.viewport.width - 1, frame.cursor.x));
    screen.diff.push({
        type: 'stdout',
        content: cursorPosition(targetY + 1, targetX + 1),
    });
    return screen.diff;
}
/**
 * Render a slice of rows from the frame's screen.
 * Each row is rendered followed by a newline. Cursor ends at (0, endY).
 *
 * Used by the cell-diff growth path to append newly-revealed rows that
 * extend the React tree past the previous frame's height — the LF advance
 * naturally scrolls overflow into terminal scrollback, the same
 * mechanism the legacy fullResetSequence_CAUSES_FLICKER relied on
 * (now superseded by safeFullRepaint for full-screen repaints).
 */
function renderFrameSlice(screen, frame, startY, endY, stylePool) {
    let currentStyleId = stylePool.none;
    let currentHyperlink = undefined;
    // Track the styleId of the last rendered cell on this line (-1 if none).
    // Passed to visibleCellAtIndex to enable fg-only space optimization.
    let lastRenderedStyleId = -1;
    const { width: screenWidth, cells, charPool, hyperlinkPool } = frame.screen;
    let index = startY * screenWidth;
    for (let y = startY; y < endY; y += 1) {
        // Advance cursor to this row using LF (not CSI CUD / cursor-down).
        // CSI CUD stops at the viewport bottom margin and cannot scroll,
        // but LF scrolls the viewport to create new lines. Without this,
        // when the cursor is at the viewport bottom, moveCursorTo's
        // cursor-down silently fails, creating a permanent off-by-one
        // between the virtual cursor and the real terminal cursor.
        if (screen.cursor.y < y) {
            const rowsToAdvance = y - screen.cursor.y;
            screen.txn(prev => {
                const patches = new Array(1 + rowsToAdvance);
                patches[0] = CARRIAGE_RETURN;
                for (let i = 0; i < rowsToAdvance; i++) {
                    patches[1 + i] = NEWLINE;
                }
                return [patches, { dx: -prev.x, dy: rowsToAdvance }];
            });
        }
        // Reset at start of each line — no cell rendered yet
        lastRenderedStyleId = -1;
        for (let x = 0; x < screenWidth; x += 1, index += 1) {
            // Skip spacers, unstyled empty cells, and fg-only styled spaces that
            // match the last rendered style (since cursor-forward produces identical
            // visual result). visibleCellAtIndex handles the optimization internally
            // to avoid allocating Cell objects for skipped cells.
            const cell = visibleCellAtIndex(cells, charPool, hyperlinkPool, index, lastRenderedStyleId);
            if (!cell) {
                continue;
            }
            moveCursorTo(screen, x, y);
            // Handle hyperlink
            const targetHyperlink = cell.hyperlink;
            currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, targetHyperlink);
            // Style transition — cached string, zero allocations after warmup
            const styleStr = stylePool.transition(currentStyleId, cell.styleId);
            if (writeCellWithStyleStr(screen, cell, styleStr)) {
                currentStyleId = cell.styleId;
                lastRenderedStyleId = cell.styleId;
            }
        }
        // Reset styles/hyperlinks before newline so background color doesn't
        // bleed into the next line when the terminal scrolls. The old code
        // reset implicitly by writing trailing unstyled spaces; now that we
        // skip empty cells, we must reset explicitly.
        currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none);
        currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined);
        // CR+LF at end of row — \r resets to column 0, \n moves to next line.
        // Without \r, the terminal cursor stays at whatever column content ended
        // (since we skip trailing spaces, this can be mid-row).
        screen.txn(prev => [[CARRIAGE_RETURN, NEWLINE], { dx: -prev.x, dy: 1 }]);
    }
    // Reset any open style/hyperlink at end of slice
    transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none);
    transitionHyperlink(screen.diff, currentHyperlink, undefined);
    return screen;
}
/**
 * Write a cell with a pre-serialized style transition string (from
 * StylePool.transition). Inlines the txn logic to avoid closure/tuple/delta
 * allocations on every cell.
 *
 * Returns true if the cell was written, false if skipped (wide char at
 * viewport edge). Callers MUST gate currentStyleId updates on this — when
 * skipped, styleStr is never pushed and the terminal's style state is
 * unchanged. Updating the virtual tracker anyway desyncs it from the
 * terminal, and the next transition is computed from phantom state.
 */
function writeCellWithStyleStr(screen, cell, styleStr) {
    const cellWidth = cell.width === 1 /* CellWidth.Wide */ ? 2 : 1;
    const px = screen.cursor.x;
    const vw = screen.viewportWidth;
    // Don't write wide chars that would cross the viewport edge.
    // Single-codepoint chars (CJK) at vw-2 are safe; multi-codepoint
    // graphemes (flags, ZWJ emoji) need stricter threshold.
    if (cellWidth === 2 && px < vw) {
        const threshold = cell.char.length > 2 ? vw : vw + 1;
        if (px + 2 >= threshold) {
            return false;
        }
    }
    const diff = screen.diff;
    if (styleStr.length > 0) {
        diff.push({ type: 'styleStr', str: styleStr });
    }
    const needsCompensation = cellWidth === 2 && needsWidthCompensation(cell.char);
    // On terminals with old wcwidth tables, a compensated emoji only advances
    // the cursor 1 column, so the CHA below skips column x+1 without painting
    // it. Write a styled space there first — on correct terminals the emoji
    // glyph (width 2) overwrites it harmlessly; on old terminals it fills the
    // gap with the emoji's background. Also clears any stale content at x+1.
    // CHA is 1-based, so column px+1 (0-based) is CHA target px+2.
    if (needsCompensation && px + 1 < vw) {
        diff.push({ type: 'cursorTo', col: px + 2 });
        diff.push({ type: 'stdout', content: ' ' });
        diff.push({ type: 'cursorTo', col: px + 1 });
    }
    diff.push({ type: 'stdout', content: cell.char });
    // Force terminal cursor to correct column after the emoji.
    if (needsCompensation) {
        diff.push({ type: 'cursorTo', col: px + cellWidth + 1 });
    }
    // Update cursor — mutate in place to avoid Point allocation
    if (px >= vw) {
        screen.cursor.x = cellWidth;
        screen.cursor.y++;
    }
    else {
        screen.cursor.x = px + cellWidth;
    }
    return true;
}
function moveCursorTo(screen, targetX, targetY) {
    screen.txn(prev => {
        const dx = targetX - prev.x;
        const dy = targetY - prev.y;
        const inPendingWrap = prev.x >= screen.viewportWidth;
        // If we're in pending wrap state (cursor.x >= width), use CR
        // to reset to column 0 on the current line without advancing
        // to the next line, then issue the cursor movement.
        if (inPendingWrap) {
            return [
                [CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }],
                { dx, dy },
            ];
        }
        // When moving to a different line, use carriage return (\r) to reset to
        // column 0 first, then cursor move.
        if (dy !== 0) {
            return [
                [CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }],
                { dx, dy },
            ];
        }
        // Standard same-line cursor move
        return [[{ type: 'cursorMove', x: dx, y: dy }], { dx, dy }];
    });
}
/**
 * Identify emoji where the terminal's wcwidth may disagree with Unicode.
 * On terminals with correct tables, the CHA we emit is a harmless no-op.
 *
 * Two categories:
 * 1. Newer emoji (Unicode 12.0+) missing from terminal wcwidth tables.
 * 2. Text-by-default emoji + VS16 (U+FE0F): the base codepoint is width 1
 *    in wcwidth, but VS16 triggers emoji presentation making it width 2.
 *    Examples: ⚔️ (U+2694), ☠️ (U+2620), ❤️ (U+2764).
 */
function needsWidthCompensation(char) {
    const cp = char.codePointAt(0);
    if (cp === undefined)
        return false;
    // U+1FA70-U+1FAFF: Symbols and Pictographs Extended-A (Unicode 12.0-15.0)
    // U+1FB00-U+1FBFF: Symbols for Legacy Computing (Unicode 13.0)
    if ((cp >= 0x1fa70 && cp <= 0x1faff) || (cp >= 0x1fb00 && cp <= 0x1fbff)) {
        return true;
    }
    // Text-by-default emoji with VS16: scan for U+FE0F in multi-codepoint
    // graphemes. Single BMP chars (length 1) and surrogate pairs without VS16
    // skip this check. VS16 (0xFE0F) can't collide with surrogates (0xD800-0xDFFF).
    if (char.length >= 2) {
        for (let i = 0; i < char.length; i++) {
            if (char.charCodeAt(i) === 0xfe0f)
                return true;
        }
    }
    return false;
}
class VirtualScreen {
    viewportWidth;
    // Public for direct mutation by writeCellWithStyleStr (avoids txn overhead).
    // File-private class — not exposed outside log-update.ts.
    cursor;
    diff = [];
    constructor(origin, viewportWidth) {
        this.viewportWidth = viewportWidth;
        this.cursor = { ...origin };
    }
    txn(fn) {
        const [patches, next] = fn(this.cursor);
        for (const patch of patches) {
            this.diff.push(patch);
        }
        this.cursor.x += next.dx;
        this.cursor.y += next.dy;
    }
}
//# sourceMappingURL=log-update.js.map