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';
const noop = () => { };
const rainbowColors = [
    'red',
    'green',
    'yellow',
    'blue',
    'magenta',
    'cyan',
    'white',
    'blackBright',
    'redBright',
    'greenBright',
    'yellowBright',
    'blueBright',
    'magentaBright',
    'cyanBright',
    'whiteBright',
];
export default class Ink {
    options;
    log;
    throttledLog;
    isScreenReaderEnabled;
    selection;
    // Ignore last render after unmounting a tree to prevent empty output before exit
    isUnmounted;
    lastOutput;
    lastOutputHeight;
    lastCursorPosition;
    container;
    rootNode;
    // 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;
        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;
                    onRender();
                });
            }
            : onRender;
        this.rootNode.onRender = renderMethod;
        this.unsubscribeSelection = this.selection.onChange(renderMethod);
        this.rootNode.onImmediateRender = options.standardReactLayoutTiming
            ? renderMethod
            : this.onRender; // Original unthrottled method
        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 = () => {
        this.calculateLayout();
        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 = () => {
        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 } = render(this.rootNode, this.isScreenReaderEnabled, this.selection, this.options.selectionStyle);
        // If <Static> output isn't empty, it means new children have been added to it
        const hasStaticOutput = staticOutput && staticOutput !== '\n';
        if (this.options.debug) {
            if (hasStaticOutput) {
                this.fullStaticOutput += staticOutput;
            }
            this.options.stdout.write(this.fullStaticOutput + output);
            this.callOnRender(startTime, output, staticOutput);
            return;
        }
        if (isInCi) {
            if (hasStaticOutput) {
                this.options.stdout.write(staticOutput);
            }
            this.lastOutput = output;
            this.lastOutputHeight = outputHeight;
            this.callOnRender(startTime, output, staticOutput);
            return;
        }
        if (this.options.alternateBuffer) {
            if (hasStaticOutput) {
                this.fullStaticOutput += staticOutput;
            }
            this.log(this.fullStaticOutput + output, styledOutput, debugRainbowColor, cursorPosition);
            this.lastOutput = output;
            this.callOnRender(startTime, output, staticOutput);
            return;
        }
        if (this.isScreenReaderEnabled) {
            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) {
                this.callOnRender(startTime, output, staticOutput);
                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;
            this.callOnRender(startTime, output, staticOutput);
            return;
        }
        if (hasStaticOutput) {
            this.fullStaticOutput += staticOutput;
        }
        if (this.lastOutputHeight >= this.options.stdout.rows) {
            // 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);
            this.callOnRender(startTime, output, staticOutput);
            return;
        }
        // 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;
        this.callOnRender(startTime, output, staticOutput);
    };
    recalculateLayout() {
        this.markAllTextNodesDirty(this.rootNode);
        this.calculateLayout();
        this.onRender();
    }
    onRerender = () => {
        if (this.isUnmounted) {
            return;
        }
        this.log.clear();
        this.lastOutput = '';
        this.onRender();
    };
    render(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, onExit: this.unmount, onRerender: this.onRerender }, node)));
        // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
        // eslint-disable-next-line @typescript-eslint/no-unsafe-call
        reconciler.updateContainerSync(tree, this.container, null, noop);
        // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
        // eslint-disable-next-line @typescript-eslint/no-unsafe-call
        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();
        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();
        }
        this.isUnmounted = true;
        // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
        // eslint-disable-next-line @typescript-eslint/no-unsafe-call
        reconciler.updateContainerSync(null, this.container, null, noop);
        // @ts-expect-error the types for `react-reconciler` are not up to date with the library.
        // eslint-disable-next-line @typescript-eslint/no-unsafe-call
        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;
    }
    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);
                }
            }
        });
    }
    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