/**
 * @license
 * Copyright 2022-2026 Greg Lauckhart <greg@lauckhart.com>
 * SPDX-License-Identifier: Apache-2.0
 */

import { Consumer } from "./consumer.js";
import { ContiguousOutputSegment } from "./contiguous-output-segment.js";
import { Producer } from "./producer.js";
import { SgrOpcode } from "./sgr.js";
import { Style } from "./style.js";
import { Token } from "./token.js";
import { tokenize } from "./tokenize.js";

const DEFAULT_TAB_WIDTH = 4;

/**
 * Low-level text output with ANSI escape sequences, maintaining state regarding style and width of current line.
 */
export class TextWriter implements Consumer {
    tabWidth: number;

    #terminalWidth?: number;
    #currentLineWidth = 0;
    #out: (text: string) => void;
    #activeStyle = Style.Inherit;
    #styleDirty = true;
    #currentStyle = Style.Inherit;
    #linePrefix?: ContiguousOutputSegment;
    #state: Consumer.State;
    #styleEnabled = true;
    #isNewLine = true;
    #buffer?: string[];
    #revertHandlers?: Set<() => void>;

    constructor(out: (text: string) => void, options?: Writer.Options) {
        const { tabWidth, terminalWidth, linePrefix } = options ?? {};
        this.#out = out;
        this.tabWidth = tabWidth ?? DEFAULT_TAB_WIDTH;
        this.#terminalWidth = terminalWidth;
        if (linePrefix) {
            this.#linePrefix = new ContiguousOutputSegment(...tokenize(linePrefix));
        }

        const self = this;

        this.#state = function state(options?: Consumer.Options, executor?: () => void): Consumer.Context | undefined {
            options = { ...options };

            const revertTo = { ...self.state, options } as Consumer.Options;

            const { linePrefix, style, buffer, wrap, wrapIndent } = options;
            if (wrap !== undefined) {
                self.#state.wrap = wrap;
            }
            if (wrapIndent !== undefined) {
                self.#state.wrapIndent = wrapIndent;
            }
            if (linePrefix) {
                if (self.#linePrefix) {
                    self.#linePrefix.push(linePrefix);
                } else {
                    self.#linePrefix = new ContiguousOutputSegment(linePrefix);
                }
            }

            if (style) {
                if (self.#currentStyle) {
                    self.#currentStyle = self.#currentStyle.addStyle(style);
                } else {
                    self.#currentStyle = style;
                }
                self.#styleDirty = true;
            }

            if (!!buffer !== !!self.#buffer) {
                if (buffer) {
                    self.#buffer = [];
                } else {
                    self.#buffer = undefined;
                }
            }

            const context = {
                close() {
                    if (self.#revertHandlers) {
                        for (const fn of self.#revertHandlers) {
                            fn();
                        }
                    }

                    if (revertTo.style) {
                        revertTo.style = self.#currentStyle.undoStyle.addStyle(revertTo.style);
                    } else {
                        revertTo.style = Style.None;
                    }

                    for (const key in revertTo) {
                        (self.state as any)[key] = (revertTo as any)[key];
                    }
                },

                [Symbol.dispose]() {
                    this.close();
                },
            };

            if (executor) {
                try {
                    executor();
                } finally {
                    context.close();
                }
            } else {
                return context;
            }
        } as Consumer.State;

        Object.defineProperties(this.#state, {
            style: {
                get(this: never) {
                    return self.#currentStyle;
                },

                set(this: never, style) {
                    self.#currentStyle = style ?? Style.Inherit;
                    self.#styleDirty = true;
                },

                enumerable: true,
            },

            linePrefix: {
                get(this: never) {
                    return self.#linePrefix?.toString();
                },

                set(this: never, prefix) {
                    if (prefix) {
                        self.#linePrefix = new ContiguousOutputSegment(...tokenize(prefix));
                    } else {
                        self.#linePrefix = undefined;
                    }
                },

                enumerable: true,
            },

            terminalWidth: {
                get(this: never) {
                    return self.#terminalWidth;
                },

                set(this: never, width: number | undefined) {
                    self.#terminalWidth = width;
                },

                enumerable: true,
            },

            styleEnabled: {
                get(this: never) {
                    return self.#styleEnabled;
                },

                set(this: never, enabled: boolean) {
                    self.#styleEnabled = enabled;
                },

                enumerable: true,
            },

            availableWidth: {
                get(this: never) {
                    if (self.#terminalWidth === undefined) {
                        return undefined;
                    }

                    if (self.#linePrefix) {
                        const width = self.#terminalWidth - self.#linePrefix.width;
                        if (width > 0) {
                            return width;
                        }
                        return undefined;
                    }

                    return self.#terminalWidth;
                },
            },

            remainingWidth: {
                get(this: never) {
                    if (self.#terminalWidth === undefined) {
                        return undefined;
                    }
                    return self.#terminalWidth - self.#currentLineWidth;
                },
            },

            buffer: {
                get(this: never) {
                    return !!self.#buffer;
                },

                set(this: never, buffering: boolean) {
                    if (!!self.#buffer === buffering) {
                        return;
                    }
                    if (buffering) {
                        self.#buffer = [];
                        return;
                    }
                    if (self.#buffer) {
                        self.#out(self.#buffer.join(""));
                        self.#buffer = undefined;
                    }
                },

                enumerable: true,
            },

            wrap: {
                value: undefined,
                writable: true,
                enumerable: true,
            },

            wrapIndent: {
                value: undefined,
                writable: true,
                enumerable: true,
            },

            onRevert: {
                value(fn: () => void) {
                    if (!self.#revertHandlers) {
                        self.#revertHandlers = new Set();
                    }
                    self.#revertHandlers.add(fn);
                },
            },

            offRevert: {
                value(fn: () => void) {
                    if (self.#revertHandlers) {
                        self.#revertHandlers.add(fn);
                    }
                },
            },
        });
    }

    get state() {
        return this.#state;
    }

    write(...text: Producer.Sequence) {
        for (const token of Producer.of(text)) {
            if (token.kind === "ansi" && (token.sequence.startsWith("\x1b[<") || token.sequence.startsWith("\x1b[>"))) {
                // We reserve these prefices for internal use and some terminals (ahem github) don't properly hide the
                // entire sequence
                continue;
            }

            this.#addToken(token);
        }
    }

    close() {
        this.#writeStyle();
    }

    /**
     * Reset styling.
     */
    reset() {
        if (this.#currentStyle.styleCodes.find(code => code !== SgrOpcode.reset)) {
            this.#currentStyle = Style.None;
            this.#styleDirty = true;
        }
    }

    #addToken(token: Token, forLinePrefix = false) {
        if (this.#isNewLine && this.#linePrefix && !forLinePrefix) {
            this.#isNewLine = false;
            const style = this.#currentStyle;
            this.#currentStyle = Style.None;
            this.#styleDirty = true;
            for (const token of this.#linePrefix.tokens) {
                this.#addToken(token, true);
            }
            this.#currentStyle = style;
            this.#styleDirty = true;
        }

        switch (token.kind) {
            case "newline":
                this.#emitLine("\n");
                break;

            case "carriage-return":
                this.#emitLine("\r");
                break;

            case "tab":
                let width = this.tabWidth - (this.#currentLineWidth % this.tabWidth);
                if (width === 0) {
                    width = this.tabWidth;
                }
                this.#writeText("".padStart(width));
                this.#currentLineWidth += width;
                break;

            case "breaking":
                this.#writeText(token.str);
                this.#currentLineWidth += token.width;
                break;

            case "style":
                this.#currentStyle = this.#currentStyle.addStyle(token.style);
                this.#styleDirty = true;
                break;

            case "ansi":
                if (this.#styleEnabled) {
                    this.#writeText(token.sequence);
                }
                if (token.newColumn !== undefined) {
                    // TODO - don't need to support yet but to be correct should adjust sequence to account for indent
                    if (!this.#currentLineWidth) {
                        this.#isNewLine = true;
                    }
                    this.#currentLineWidth = token.newColumn;
                }
                break;

            case "nonbreaking":
                this.#writeText(token.str);
                this.#currentLineWidth += token.width;
                break;
        }
    }

    #writeText(text: string) {
        this.#writeStyle();
        this.#emit(text);
    }

    #writeStyle() {
        if (this.#styleDirty && this.#styleEnabled) {
            this.#styleDirty = false;

            this.#emit(this.#activeStyle.diffStyle(this.#currentStyle).toString());

            this.#activeStyle = this.#currentStyle;
        }
    }

    #emit(text: string) {
        if (this.#buffer) {
            this.#buffer.push(text);
        } else {
            this.#out(text);
        }
    }

    #emitLine(terminator?: string) {
        this.#writeStyle();
        this.#beginLine();
        if (terminator) {
            this.#emit(terminator);
        }
    }

    #beginLine() {
        this.#isNewLine = true;
        this.#currentLineWidth = 0;
    }
}

export namespace Writer {
    export interface Options {
        tabWidth?: number;
        terminalWidth?: number;
        linePrefix?: string;
    }
}
