/**
 * @license
 * Copyright 2022-2026 Matter.js Authors
 * SPDX-License-Identifier: Apache-2.0
 */

const INDENT = "  ";

export { describeList } from "./String.js";

/**
 * Performs word wrap.  Input is assumed to be a series of paragraphs separated by a newline.  Output is an array of
 * formatted lines.
 *
 * Contains specialized support for lists, ESDoc directives and ANSI escape codes.
 */
export function FormattedText(text: string, width = 120) {
    const structure = detectStructure(text);
    return formatBlock(structure, width);
}

/**
 * Types of things we consider "blocks".  Most blocks are lists but we also support markdown-style quotes prefixed with
 * ">".
 */
export enum BlockKind {
    Simple = "simple",
    Bullet1 = "-",
    Bullet2 = "•",
    Bullet3 = "◦",
    Bullet4 = "▪",
    Bullet5 = "○",
    Bullet6 = "●",
    Bullet7 = "‣",
    Bullet8 = "⁃",
    Quote = ">",
    Number = "number",
    LowerAlpha = "alpha",
    UpperAlpha = "ALPHA",
    LowerRoman = "roman",
    UpperRoman = "ROMAN",
}

export const Bullets = Object.entries(BlockKind)
    .filter(([key]) => key.startsWith("Bullet"))
    .map(([, value]) => value);

const enumTest = "(?:\\d+|[ivx]+|[a-z])\\.";
const listItemTest = new RegExp(`^(?:[${Bullets.join("")}]|${enumTest})\\s`, "i");

export function looksLikeListItem(text: string) {
    return !!listItemTest.exec(text);
}

type Block = {
    kind: BlockKind;
    indentWidth: number;
    sourceIndent?: number;
    entries: (string | Block)[];
};

const Empty: Block = {
    kind: BlockKind.Simple,
    indentWidth: 0,
    entries: [],
};

/**
 * Detect block prefixes.  Uses indentation to determine nesting depth for markdown-style `-` bullets
 * (where all levels use the same marker), and marker identity for other bullet/enumeration types.
 */
function detectBlock(text: string, breadcrumb: Block[]) {
    const match = text.match(/^(\s*)(\S+)/);
    if (!match) {
        return;
    }

    const [, leadingSpace, marker] = match;
    const indent = leadingSpace.length;

    if (Bullets.includes(marker as BlockKind) || marker === BlockKind.Quote) {
        enterBlock(marker as BlockKind, indent);
        return;
    }

    if (detectEnumeration(/^\d+\.$/, "1", BlockKind.Number)) return;
    if (detectEnumeration(/^[ivx]+\.$/, "i", BlockKind.LowerRoman)) return;
    if (detectEnumeration(/^[IVX]+\.$/, "I", BlockKind.UpperRoman)) return;
    if (detectEnumeration(/^[a-z]+\.$/, "a", BlockKind.LowerAlpha)) return;
    if (detectEnumeration(/^[A-Z]+\.$/, "A", BlockKind.UpperAlpha)) return;

    // Not in a block
    breadcrumb.length = 1;

    function enterBlock(kind: BlockKind, sourceIndent?: number) {
        // For `-` markers with indentation, find the right nesting level by matching indent
        if (sourceIndent !== undefined && kind === BlockKind.Bullet1) {
            // Find the Bullet1 block whose sourceIndent matches (or is closest-smaller)
            let matchLevel = -1;
            for (let i = breadcrumb.length - 1; i >= 0; i--) {
                if (breadcrumb[i].kind === kind) {
                    if (breadcrumb[i].sourceIndent !== undefined && breadcrumb[i].sourceIndent! <= sourceIndent) {
                        matchLevel = i;
                        if (breadcrumb[i].sourceIndent === sourceIndent) {
                            // Exact match — pop to this level
                            breadcrumb.length = matchLevel + 1;
                            return;
                        }
                        // sourceIndent is larger — fall through to create nested sub-block
                        break;
                    }
                }
            }
            // If we found a shallower block but indent is less than any existing, pop to shallowest
            if (matchLevel === -1) {
                for (let i = 0; i < breadcrumb.length; i++) {
                    if (breadcrumb[i].kind === kind) {
                        breadcrumb.length = i + 1;
                        return;
                    }
                }
            }
        } else {
            const level = breadcrumb.findIndex(entry => entry.kind === kind);
            if (level !== -1) {
                breadcrumb.length = level + 1;
                return;
            }
        }

        // Need to start a new block
        const block: Block = {
            kind,
            indentWidth: (breadcrumb[breadcrumb.length - 1]?.indentWidth ?? 0) + kind === BlockKind.Quote ? 0 : 2,
            sourceIndent,
            entries: [],
        };

        breadcrumb[breadcrumb.length - 1].entries.push(block);
        breadcrumb.push(block);
    }

    function detectEnumeration(test: RegExp, startsWith: string, kind: BlockKind) {
        if (!marker.match(test)) {
            return false;
        }

        // Only consider enumeration if a.) we are already in same type of enumeration, or b.) the marker is the first
        // element of the enumeration (e.g. "1." or "i.")
        if (!breadcrumb.find(block => block.kind === kind)) {
            if (marker !== `${startsWith}.`) {
                return false;
            }
        }

        enterBlock(kind);
        return true;
    }
}

/**
 * Builds a block structure by detecting lists and/or quoted sections.
 */
function detectStructure(text: string): Block {
    const lines = text.split(/\n+/).map(line => line.trimEnd());
    if (!lines.some(p => p)) {
        return Empty;
    }

    const breadcrumb: Block[] = [{ ...Empty, entries: [] }];

    for (const line of lines) {
        detectBlock(line, breadcrumb);
        breadcrumb[breadcrumb.length - 1].entries.push(line.trim().replace(/\s+/g, " "));
    }

    return breadcrumb[0];
}

function wrapParagraph(input: string, into: string[], wrapWidth: number, initialPrefix: string, wrapPrefix: string) {
    const prefixWidth = visibleWidthOf(initialPrefix);
    const segments = input.split(/\s+/);
    if (!segments) {
        return;
    }

    // Reassemble text surrounded by "{@" and "}" as this is likely an ESDoc directive and ESDoc doesn't like directives
    // wrapped
    for (let i = 0; i < segments?.length; i++) {
        if (!segments[i].includes("{@")) {
            continue;
        }
        for (let j = i; j < segments.length; j++) {
            if (segments[j].includes("}")) {
                segments.splice(i, j - i + 1, segments.slice(i, j + 1).join(" "));
                break;
            }
        }
    }

    // Wrapping setup.  Track the portions of the line and current length
    const line = [initialPrefix];
    let width = prefixWidth;

    // Perform actual wrapping
    let pushedOne = false;
    for (const s of segments) {
        const segmentWidth = visibleWidthOf(s);

        // If we'll extend too far, start on a new line
        if (width && width + segmentWidth > wrapWidth) {
            addLine();
            line.length = 0;
            width = prefixWidth;
        }

        // Add wrap prefix if this is a new line
        if (!line.length) {
            line.push(wrapPrefix);
            width = prefixWidth;
        }

        // Add to the line
        line.push(s);
        line.push(" ");
        width += segmentWidth + 1;
    }

    // If there is a remaining line, add it
    line.length = line.length - 1; // Remove ending space
    if (line.length) {
        addLine();
    }

    function addLine() {
        if (!pushedOne) {
            if (into.length) {
                into.push("");
            }
            pushedOne = true;
        }

        into.push(line.join(""));
    }
}

function separatePrefixFromContent(text: string) {
    const match = text.match(/^(\S+\s)\s*(\S.*$)/);
    if (match) {
        return { prefix: match[1], text: match[2] };
    }
    return { prefix: "", text };
}

function formatBlock(block: Block, width: number) {
    const lines = Array<string>();

    function formatLevel(block: Block, parentPrefix: string) {
        for (const entry of block.entries) {
            if (typeof entry === "string") {
                let prefix, text;
                if (block.kind === BlockKind.Simple) {
                    prefix = "";
                    text = entry;
                } else {
                    ({ prefix, text } = separatePrefixFromContent(entry));
                }

                wrapParagraph(
                    text,
                    lines,
                    width,
                    parentPrefix + prefix,
                    parentPrefix + " ".repeat(visibleWidthOf(prefix)),
                );
            } else {
                let childPrefix = parentPrefix;
                if (entry.kind !== BlockKind.Quote || parentPrefix !== "") {
                    childPrefix += INDENT;
                }
                formatLevel(entry, childPrefix);
            }
        }
    }

    formatLevel(block, "");

    return lines;
}

function visibleWidthOf(text: string) {
    let length = 0;
    for (let i = 0; i < text.length; ) {
        switch (text[i]) {
            case `\u001b`:
                // Escape
                i++;
                const code = text[i];

                if ((code >= "@" && code <= "Z") || code === "-" || code === "_") {
                    // Fe except CSI (control sequence introducer)
                    i++;
                    break;
                }

                if (code === "[") {
                    // CSI
                    i++;
                    while (text[i] >= "0" && text[i] <= "?") {
                        // Parameter
                        i++;
                    }
                    while (text[i] >= " " && text[i] <= "/") {
                        // Intermediate
                        i++;
                    }
                    if (text[i] >= "@" && text[i] <= "~") {
                        // Final
                        i++;
                        break;
                    }
                }

                break;

            case `\u200b`:
                // Zero-width space
                i++;
                break;

            default:
                i++;
                length++;
                break;
        }
    }
    return length;
}
