import process from 'node:process';
import React from 'react';
import { throttle } from 'es-toolkit/compat';
import ansiEscapes from 'ansi-escapes';
import isInCi from 'is-in-ci';
import autoBind from 'auto-bind';
import signalExit from 'signal-exit';
import patchConsole from 'patch-console';
import { LegacyRoot } from 'react-reconciler/constants.js';
import Yoga from 'yoga-layout';
import wrapAnsi from 'wrap-ansi';
import reconciler from './reconciler.js';
import render from './renderer.js';
import * as dom from './dom.js';
import logUpdate, { positionImeCursor } from './log-update.js';
import instances from './instances.js';
import App from './components/App.js';
import { accessibilityContext as AccessibilityContext } from './components/AccessibilityContext.js';
import { calculateScroll } from './scroll.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { ResizeObserverEntry } from './resize-observer.js';
import { Selection } from './selection.js';
import TerminalBuffer from './terminal-buffer.js';
import { setEnableToStyledCharactersCache, clearToStyledCharactersCache, } from './measure-text.js';
const noop = () => { };
const rainbowColors = [
    'red',
    'green',
    'yellow',
    'blue',
    'magenta',
    'cyan',
    'white',
    'blackBright',
    'redBright',
    'greenBright',
    'yellowBright',
    'blueBright',
    'magentaBright',
    'cyanBright',
    'whiteBright',
];
export default class Ink {
    rootNode;
    options;
    log;
    throttledLog;
    isScreenReaderEnabled;
    selection;
    terminalBuffer;
    optionsState;
    // Ignore last render after unmounting a tree to prevent empty output before exit
    isUnmounted;
    lastOutput;
    lastOutputHeight;
    lastCursorPosition;
    container;
    node;
    // This variable is used only in debug mode to store full static output
    // so that it's rerendered every time, not just new static parts, like in non-debug mode
    fullStaticOutput;
    exitPromise;
    restoreConsole;
    unsubscribeResize;
    unsubscribeSelection;
    frameIndex = 0;
    constructor(options) {
        autoBind(this);
        this.options = options;
        if (options.cacheToStyledCharacters === false) {
            setEnableToStyledCharactersCache(false);
        }
        else if (options.cacheToStyledCharacters === true) {
            setEnableToStyledCharactersCache(true);
        }
        this.optionsState = {
            isAlternateBufferEnabled: options.isAlternateBufferEnabled ?? options.alternateBuffer,
            stickyHeadersInBackbuffer: options.stickyHeadersInBackbuffer,
            animatedScroll: options.animatedScroll,
            animationInterval: options.animationInterval,
            backbufferUpdateDelay: options.backbufferUpdateDelay,
            maxScrollbackLength: options.maxScrollbackLength,
            forceScrollToBottomOnBackbufferRefresh: options.forceScrollToBottomOnBackbufferRefresh,
        };
        this.rootNode = dom.createNode('ink-root');
        this.rootNode.onComputeLayout = this.calculateLayout;
        this.isScreenReaderEnabled =
            options.isScreenReaderEnabled ??
                process.env['INK_SCREEN_READER'] === 'true';
        this.selection = new Selection();
        const unthrottled = options.debug || this.isScreenReaderEnabled;
        const maxFps = options.maxFps ?? 30;
        const renderThrottleMs = maxFps > 0 ? Math.max(1, Math.ceil(1000 / maxFps)) : 0;
        const onRender = unthrottled
            ? this.onRender
            : throttle(this.onRender, renderThrottleMs, {
                leading: true,
                trailing: true,
            });
        let isRenderScheduled = false;
        const renderMethod = options.standardReactLayoutTiming
            ? () => {
                if (isRenderScheduled)
                    return;
                isRenderScheduled = true;
                queueMicrotask(() => {
                    isRenderScheduled = false;
                    void onRender();
                });
            }
            : onRender;
        this.rootNode.onRender = renderMethod;
        this.unsubscribeSelection = this.selection.onChange(renderMethod);
        this.rootNode.onImmediateRender = options.standardReactLayoutTiming
            ? renderMethod
            : this.onRender; // Original unthrottled method
        if (options.renderProcess === true || options.terminalBuffer === true) {
            this.terminalBuffer = new TerminalBuffer(options.stdout.columns ?? 80, options.stdout.rows ?? 24, {
                debugRainbowEnabled: options.debugRainbow,
                renderInProcess: !options.renderProcess && options.terminalBuffer,
                stdout: options.stdout,
                isAlternateBufferEnabled: options.isAlternateBufferEnabled,
                stickyHeadersInBackbuffer: options.stickyHeadersInBackbuffer,
                animatedScroll: options.animatedScroll,
                animationInterval: options.animationInterval,
                backbufferUpdateDelay: options.backbufferUpdateDelay,
                maxScrollbackLength: options.maxScrollbackLength,
                forceScrollToBottomOnBackbufferRefresh: options.forceScrollToBottomOnBackbufferRefresh,
                cacheToStyledCharacters: options.cacheToStyledCharacters,
            });
        }
        this.log = logUpdate.create(options.stdout, {
            alternateBuffer: options.alternateBuffer,
            alternateBufferAlreadyActive: options.alternateBufferAlreadyActive,
            getRows: () => options.stdout.rows,
            getColumns: () => options.stdout.columns,
            incremental: options.incrementalRendering,
        });
        this.throttledLog = unthrottled
            ? this.log
            : throttle(this.log, undefined, {
                leading: true,
                trailing: true,
            });
        // Ignore last render after unmounting a tree to prevent empty output before exit
        this.isUnmounted = false;
        // Store last output to only rerender when needed
        this.lastOutput = '';
        this.lastOutputHeight = 0;
        // This variable is used only in debug mode to store full static output
        // so that it's rerendered every time, not just new static parts, like in non-debug mode
        this.fullStaticOutput = '';
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        this.container = reconciler.createContainer(this.rootNode, LegacyRoot, null, false, null, 'id', () => { }, () => { }, () => { }, () => { }, null);
        // Unmount when process exits
        this.unsubscribeExit = signalExit(this.unmount, { alwaysLast: false });
        if (process.env['DEV'] === 'true') {
            reconciler.injectIntoDevTools({
                bundleType: 0,
                // Reporting React DOM's version, not Ink's
                // See https://github.com/facebook/react/issues/16666#issuecomment-532639905
                version: '16.13.1',
                rendererPackageName: 'ink',
            });
        }
        if (options.patchConsole) {
            this.patchConsole();
        }
        if (!isInCi) {
            options.stdout.on('resize', this.resized);
            this.unsubscribeResize = () => {
                options.stdout.off('resize', this.resized);
            };
        }
    }
    resized = () => {
        const terminalWidth = this.options.stdout.columns ?? 80;
        const terminalHeight = this.options.stdout.rows ?? 24;
        clearToStyledCharactersCache();
        this.terminalBuffer?.resize(terminalWidth, terminalHeight);
        this.calculateLayout();
        void this.onRender();
    };
    getSelection() {
        return this.selection;
    }
    resolveExitPromise = () => { };
    rejectExitPromise = () => { };
    unsubscribeExit = () => { };
    calculateLayout = () => {
        // The 'columns' property can be undefined or 0 when not using a TTY.
        // In that case we fall back to 80.
        const terminalWidth = this.options.stdout.columns ?? 80;
        this.rootNode.yogaNode.setWidth(terminalWidth);
        this.rootNode.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
        const observerEntries = new Map();
        this.calculateLayoutAndTriggerObservers(this.rootNode, observerEntries);
        for (const [observer, entries] of observerEntries) {
            observer.internalTrigger(entries);
        }
    };
    calculateLayoutAndTriggerObservers(node, observerEntries) {
        if (node.nodeName === 'ink-box') {
            const { style } = node;
            const overflow = style.overflow ?? 'visible';
            const overflowX = style.overflowX ?? overflow;
            const overflowY = style.overflowY ?? overflow;
            if (overflowX === 'scroll' || overflowY === 'scroll') {
                calculateScroll(node);
            }
            else if (node.internal_scrollState) {
                delete node.internal_scrollState;
            }
        }
        if (node.resizeObservers &&
            node.resizeObservers.size > 0 &&
            node.yogaNode) {
            const width = node.yogaNode.getComputedWidth();
            const height = node.yogaNode.getComputedHeight();
            const lastSize = node.internal_lastMeasuredSize;
            if (!lastSize || lastSize.width !== width || lastSize.height !== height) {
                const entry = new ResizeObserverEntry(node, { width, height });
                for (const observer of node.resizeObservers) {
                    if (!observerEntries.has(observer)) {
                        observerEntries.set(observer, []);
                    }
                    observerEntries.get(observer).push(entry);
                }
                node.internal_lastMeasuredSize = { width, height };
            }
        }
        for (const child of node.childNodes) {
            if (child.nodeName !== '#text') {
                this.calculateLayoutAndTriggerObservers(child, observerEntries);
            }
        }
    }
    onRender = async () => {
        if (this.isUnmounted) {
            return;
        }
        const startTime = performance.now();
        let debugRainbowColor;
        if (this.options.debugRainbow) {
            debugRainbowColor = rainbowColors[this.frameIndex % rainbowColors.length];
            this.frameIndex++;
        }
        const { output, outputHeight, staticOutput, styledOutput, cursorPosition, root, } = render(this.rootNode, {
            isScreenReaderEnabled: this.isScreenReaderEnabled,
            selection: this.selection,
            selectionStyle: this.options.selectionStyle,
            skipScrollbars: Boolean(this.terminalBuffer),
        });
        if (this.terminalBuffer && root) {
            await this.renderWithTerminalBuffer(root, cursorPosition);
        }
        else {
            // If <Static> output isn't empty, it means new children have been added to it
            const hasStaticOutput = Boolean(staticOutput && staticOutput !== '\n');
            const renderOptions = {
                output,
                outputHeight,
                staticOutput,
                styledOutput,
                cursorPosition,
                debugRainbowColor,
                hasStaticOutput,
            };
            if (this.options.debug) {
                this.renderDebug(renderOptions);
            }
            else if (isInCi) {
                this.renderCi(renderOptions);
            }
            else if (this.optionsState.isAlternateBufferEnabled) {
                this.renderAlternateBuffer(renderOptions);
            }
            else if (this.isScreenReaderEnabled) {
                this.renderScreenReader(renderOptions);
            }
            else if (this.lastOutputHeight >= this.options.stdout.rows) {
                this.renderFullTerminal(renderOptions);
            }
            else {
                this.renderLegacy(renderOptions);
            }
        }
        this.callOnRender(startTime, output, staticOutput);
    };
    recalculateLayout() {
        this.markAllTextNodesDirty(this.rootNode);
        this.calculateLayout();
        void this.onRender();
    }
    onRerender = () => {
        if (this.isUnmounted) {
            return;
        }
        this.log.clear();
        this.lastOutput = '';
        void this.onRender();
    };
    setOptions = (options) => {
        this.optionsState = {
            ...this.optionsState,
            ...options,
        };
        this.terminalBuffer?.updateOptions(this.optionsState);
        this.lastOutput = '';
        if (this.node) {
            this.render(this.node);
        }
        else {
            this.onRerender();
        }
    };
    render(node) {
        this.node = node;
        const tree = (React.createElement(AccessibilityContext.Provider, { value: { isScreenReaderEnabled: this.isScreenReaderEnabled } },
            React.createElement(App, { stdin: this.options.stdin, stdout: this.options.stdout, stderr: this.options.stderr, writeToStdout: this.writeToStdout, writeToStderr: this.writeToStderr, exitOnCtrlC: this.options.exitOnCtrlC, selection: this.selection, options: this.optionsState, setOptions: this.setOptions, dumpCurrentFrame: this.dumpCurrentFrame, startRecording: this.startRecording, stopRecording: this.stopRecording, onExit: this.unmount, onRerender: this.onRerender }, node)));
        // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
        reconciler.updateContainerSync(tree, this.container, null, noop);
        // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
        reconciler.flushSyncWork();
    }
    writeToStdout(data) {
        if (this.isUnmounted) {
            return;
        }
        if (this.options.debug) {
            this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput);
            return;
        }
        if (isInCi) {
            this.options.stdout.write(data);
            return;
        }
        this.log.clear();
        this.options.stdout.write(data);
        this.log(this.lastOutput, []);
    }
    writeToStderr(data) {
        if (this.isUnmounted) {
            return;
        }
        if (this.options.debug) {
            this.options.stderr.write(data);
            this.options.stdout.write(this.fullStaticOutput + this.lastOutput);
            return;
        }
        if (isInCi) {
            this.options.stderr.write(data);
            return;
        }
        this.log.clear();
        this.options.stderr.write(data);
        this.log(this.lastOutput, []);
    }
    // eslint-disable-next-line @typescript-eslint/ban-types
    unmount(error) {
        if (this.isUnmounted) {
            return;
        }
        this.calculateLayout();
        void this.onRender();
        this.unsubscribeExit();
        if (typeof this.restoreConsole === 'function') {
            this.restoreConsole();
        }
        if (typeof this.unsubscribeResize === 'function') {
            this.unsubscribeResize();
        }
        if (typeof this.unsubscribeSelection === 'function') {
            this.unsubscribeSelection();
        }
        // CIs don't handle erasing ansi escapes well, so it's better to
        // only render last frame of non-static output
        if (isInCi) {
            this.options.stdout.write(this.lastOutput + '\n');
        }
        else if (!this.options.debug) {
            this.log.done();
            if (this.terminalBuffer) {
                this.terminalBuffer.done();
            }
        }
        if (this.terminalBuffer) {
            this.terminalBuffer.destroy();
        }
        this.isUnmounted = true;
        // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
        reconciler.updateContainerSync(null, this.container, null, noop);
        // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
        reconciler.flushSyncWork();
        instances.delete(this.options.stdout);
        if (error instanceof Error) {
            this.rejectExitPromise(error);
        }
        else {
            this.resolveExitPromise();
        }
    }
    async waitUntilExit() {
        this.exitPromise ||= new Promise((resolve, reject) => {
            this.resolveExitPromise = resolve;
            this.rejectExitPromise = reject;
        });
        return this.exitPromise;
    }
    dumpCurrentFrame(filename) {
        if (this.terminalBuffer) {
            this.terminalBuffer.dumpCurrentFrame(filename);
        }
        else {
            console.error('dumpCurrentFrame is only supported when terminalBuffer is true');
        }
    }
    startRecording(filename) {
        if (this.terminalBuffer) {
            this.terminalBuffer.startRecording(filename);
        }
        else {
            console.error('startRecording is only supported when terminalBuffer is true');
        }
    }
    stopRecording() {
        if (this.terminalBuffer) {
            this.terminalBuffer.stopRecording();
        }
        else {
            console.error('stopRecording is only supported when terminalBuffer is true');
        }
    }
    clear() {
        if (!isInCi && !this.options.debug) {
            this.log.clear();
        }
    }
    patchConsole() {
        if (this.options.debug) {
            return;
        }
        this.restoreConsole = patchConsole((stream, data) => {
            if (stream === 'stdout') {
                this.writeToStdout(data);
            }
            if (stream === 'stderr') {
                const isReactMessage = data.startsWith('The above error occurred');
                if (!isReactMessage) {
                    this.writeToStderr(data);
                }
            }
        });
    }
    async renderWithTerminalBuffer(root, cursorPosition) {
        const appliedChanges = this.terminalBuffer.update(0, Number.MAX_SAFE_INTEGER, root, cursorPosition);
        if (appliedChanges) {
            await this.terminalBuffer.render();
        }
    }
    renderDebug({ output, staticOutput, hasStaticOutput, }) {
        if (hasStaticOutput) {
            this.fullStaticOutput += staticOutput;
        }
        this.options.stdout.write(this.fullStaticOutput + output);
    }
    renderCi({ output, outputHeight, staticOutput, hasStaticOutput, }) {
        if (hasStaticOutput) {
            this.options.stdout.write(staticOutput);
        }
        this.lastOutput = output;
        this.lastOutputHeight = outputHeight;
    }
    renderAlternateBuffer({ output, styledOutput, staticOutput, cursorPosition, debugRainbowColor, hasStaticOutput, }) {
        if (hasStaticOutput) {
            this.fullStaticOutput += staticOutput;
        }
        this.log(this.fullStaticOutput + output, styledOutput, debugRainbowColor, cursorPosition);
        this.lastOutput = output;
    }
    renderScreenReader({ output, staticOutput, hasStaticOutput, }) {
        if (hasStaticOutput) {
            // We need to erase the main output before writing new static output
            const erase = this.lastOutputHeight > 0
                ? ansiEscapes.eraseLines(this.lastOutputHeight)
                : '';
            this.options.stdout.write(erase + staticOutput);
            // After erasing, the last output is gone, so we should reset its height
            this.lastOutputHeight = 0;
        }
        if (output === this.lastOutput && !hasStaticOutput) {
            return;
        }
        const terminalWidth = this.options.stdout.columns ?? 80;
        const wrappedOutput = wrapAnsi(output, terminalWidth, {
            trim: false,
            hard: true,
        });
        // If we haven't erased yet, do it now.
        if (hasStaticOutput) {
            this.options.stdout.write(wrappedOutput);
        }
        else {
            const erase = this.lastOutputHeight > 0
                ? ansiEscapes.eraseLines(this.lastOutputHeight)
                : '';
            this.options.stdout.write(erase + wrappedOutput);
        }
        this.lastOutput = output;
        this.lastOutputHeight =
            wrappedOutput === '' ? 0 : wrappedOutput.split('\n').length;
    }
    renderFullTerminal({ output, outputHeight, staticOutput, cursorPosition, hasStaticOutput, }) {
        if (hasStaticOutput) {
            this.fullStaticOutput += staticOutput;
        }
        // Build a single buffer for all operations
        let buffer = '';
        buffer += ansiEscapes.clearTerminal + this.fullStaticOutput + output;
        // Position cursor after screen clear if requested by a component
        if (cursorPosition) {
            const lineCount = output.split('\n').length;
            buffer += positionImeCursor(lineCount, cursorPosition);
        }
        this.options.stdout.write(buffer);
        this.lastOutput = output;
        this.lastOutputHeight = outputHeight;
        this.lastCursorPosition = cursorPosition;
        this.log.sync(output, cursorPosition);
    }
    /**
     * The goal is to remove this method making terminal buffer required.
     */
    renderLegacy({ output, outputHeight, styledOutput, staticOutput, cursorPosition, debugRainbowColor, hasStaticOutput, }) {
        if (hasStaticOutput) {
            this.fullStaticOutput += staticOutput;
        }
        // To ensure static output is cleanly rendered before main output, clear main output first
        if (hasStaticOutput) {
            this.log.clear();
            this.options.stdout.write(staticOutput);
            this.log(output, styledOutput, debugRainbowColor, cursorPosition);
        }
        const outputChanged = output !== this.lastOutput;
        const cursorChanged = cursorPosition !== this.lastCursorPosition &&
            (!cursorPosition ||
                !this.lastCursorPosition ||
                cursorPosition.row !== this.lastCursorPosition.row ||
                cursorPosition.col !== this.lastCursorPosition.col);
        if (!hasStaticOutput && (outputChanged || cursorChanged)) {
            this.throttledLog(output, styledOutput, debugRainbowColor, cursorPosition);
        }
        this.lastOutput = output;
        this.lastOutputHeight = outputHeight;
        this.lastCursorPosition = cursorPosition;
    }
    markAllTextNodesDirty(node) {
        if (node.nodeName === 'ink-text' && node.yogaNode) {
            node.yogaNode.markDirty();
        }
        for (const child of node.childNodes) {
            if (child.nodeName !== '#text') {
                this.markAllTextNodesDirty(child);
            }
        }
    }
    callOnRender(startTime, output, staticOutput) {
        this.options.onRender?.({
            renderTime: performance.now() - startTime,
            output,
            staticOutput,
        });
    }
}
//# sourceMappingURL=ink.js.map