/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/
import * as lsp from 'vscode-languageserver-protocol';
import { HTMLElement, parse as parseHtml } from 'node-html-parser';
import { LogLevel } from './logging.js';
import { getDocUri, getLine } from './types/textDocument.js';
import { Disposable } from './util/dispose.js';
import { NoLinkRanges } from './util/noLinkRanges.js';
import { ResourceMap } from './util/resourceMap.js';
import { MdDocumentInfoCache } from './workspaceCache.js';
export class TableOfContents {
    entries;
    static async create(parser, document, token, options) {
        const tokens = await parser.tokenize(document);
        if (token.isCancellationRequested) {
            return new TableOfContents([], parser.slugifier);
        }
        const headerEntries = await this.#buildTocFromTokens(parser, document, tokens, token);
        if (token.isCancellationRequested) {
            return new TableOfContents([], parser.slugifier);
        }
        if (options?.includeHtmlIds) {
            const htmlIdEntries = this.#getHtmlIdEntries(parser, document, tokens);
            // Filter out html id entries that duplicate existing header slugs
            const headerSlugs = new Set(headerEntries.map(e => e.slug.value));
            const uniqueHtmlIds = htmlIdEntries.filter(e => !headerSlugs.has(e.slug.value));
            return new TableOfContents([...headerEntries, ...uniqueHtmlIds], parser.slugifier);
        }
        return new TableOfContents(headerEntries, parser.slugifier);
    }
    static async createForContainingDoc(parser, workspace, document, token, options) {
        const context = workspace.getContainingDocument?.(getDocUri(document));
        if (context) {
            const entries = (await Promise.all(Array.from(context.children, async (cell) => {
                const doc = await workspace.openMarkdownDocument(cell.uri);
                if (!doc || token.isCancellationRequested) {
                    return [];
                }
                return this.#buildToc(parser, doc, token, options);
            }))).flat();
            return new TableOfContents(entries, parser.slugifier);
        }
        return this.create(parser, document, token, options);
    }
    static async #buildToc(parser, document, token, options) {
        const tokens = await parser.tokenize(document);
        if (token.isCancellationRequested) {
            return [];
        }
        const headerEntries = await this.#buildTocFromTokens(parser, document, tokens, token);
        if (options?.includeHtmlIds) {
            const htmlIdEntries = this.#getHtmlIdEntries(parser, document, tokens);
            const headerSlugs = new Set(headerEntries.map(e => e.slug.value));
            const uniqueHtmlIds = htmlIdEntries.filter(e => !headerSlugs.has(e.slug.value));
            return [...headerEntries, ...uniqueHtmlIds];
        }
        return headerEntries;
    }
    static async #buildTocFromTokens(parser, document, tokens, _token) {
        const docUri = getDocUri(document);
        const toc = [];
        const slugBuilder = parser.slugifier.createBuilder();
        const headers = [];
        let currentHeader;
        for (const token of tokens) {
            switch (token.type) {
                case 'heading_open': {
                    currentHeader = { open: token, body: [] };
                    headers.push(currentHeader);
                    break;
                }
                case 'heading_close': {
                    currentHeader = undefined;
                    break;
                }
                default: {
                    currentHeader?.body.push(token);
                    break;
                }
            }
        }
        for (const { open, body } of headers) {
            if (!open.map) {
                continue;
            }
            const lineNumber = open.map[0];
            const line = getLine(document, lineNumber);
            const bodyText = TableOfContents.#getHeaderTitleAsPlainText(body);
            const slug = slugBuilder.add(bodyText);
            const headerLocation = {
                uri: docUri.toString(),
                range: lsp.Range.create(lineNumber, 0, lineNumber, line.length)
            };
            const headerTextLocation = {
                uri: docUri.toString(),
                range: lsp.Range.create(lineNumber, line.match(/^#+\s*/)?.[0].length ?? 0, lineNumber, line.length - (line.match(/\s*#*$/)?.[0].length ?? 0))
            };
            toc.push({
                kind: 'header',
                slug,
                text: bodyText.trim(),
                level: TableOfContents.#getHeaderLevel(open.markup),
                sectionLocation: headerLocation, // Populated in next steps
                declarationLocation: headerLocation,
                idDeclarationLocation: headerTextLocation
            });
        }
        // Get full range of section
        return toc.map((entry, startIndex) => {
            if (entry.kind !== 'header') {
                return entry;
            }
            ;
            let end = undefined;
            for (let i = startIndex + 1; i < toc.length; ++i) {
                const targetToc = toc[i];
                if (targetToc.kind === 'header' && targetToc.level <= entry.level) {
                    end = targetToc.declarationLocation.range.start.line - 1;
                    break;
                }
            }
            const endLine = end ?? document.lineCount - 1;
            return {
                ...entry,
                sectionLocation: {
                    uri: docUri.toString(),
                    range: lsp.Range.create(entry.sectionLocation.range.start, { line: endLine, character: getLine(document, endLine).length })
                }
            };
        });
    }
    static #getHeaderLevel(markup) {
        if (markup === '=') {
            return 1;
        }
        else if (markup === '-') {
            return 2;
        }
        else { // '#', '##', ...
            return markup.length;
        }
    }
    static #tokenToPlainText(token) {
        if (token.children) {
            return token.children.map(TableOfContents.#tokenToPlainText).join('');
        }
        switch (token.type) {
            case 'text':
            case 'emoji':
            case 'code_inline':
                return token.content;
            default:
                return '';
        }
    }
    static #getHeaderTitleAsPlainText(headerTitleParts) {
        return headerTitleParts
            .map(TableOfContents.#tokenToPlainText)
            .join('')
            .trim();
    }
    static #htmlIdAttrPattern = /\bid\s*=\s*["']/i;
    static #getHtmlIdEntries(parser, document, tokens) {
        const text = document.getText();
        if (!/<\w/.test(text)) {
            return [];
        }
        let tree;
        try {
            tree = parseHtml(text);
        }
        catch {
            return [];
        }
        const docUri = getDocUri(document);
        const noLinkRanges = NoLinkRanges.compute(tokens, document);
        const entries = [];
        const existingSlugs = new Set();
        this.#collectHtmlIdEntries(tree, document, parser, docUri, noLinkRanges, existingSlugs, entries);
        return entries;
    }
    static #collectHtmlIdEntries(node, document, parser, docUri, noLinkRanges, existingSlugs, entries) {
        const idValue = node.attributes?.['id'];
        if (idValue) {
            const attrMatch = node.outerHTML.match(this.#htmlIdAttrPattern);
            if (attrMatch) {
                const offset = node.range[0] + attrMatch.index + attrMatch[0].length;
                const position = document.positionAt(offset);
                // Exclude code blocks and inline code, but allow html_block since ids inside HTML are valid
                if (!noLinkRanges.contains(position, 'html_block')) {
                    const slug = parser.slugifier.fromFragment(idValue);
                    if (!existingSlugs.has(slug.value)) {
                        existingSlugs.add(slug.value);
                        const endPosition = document.positionAt(offset + idValue.length);
                        entries.push({
                            kind: 'html-id',
                            slug,
                            text: idValue,
                            declarationLocation: {
                                uri: docUri.toString(),
                                range: lsp.Range.create(position, endPosition),
                            },
                        });
                    }
                }
            }
        }
        for (const child of node.childNodes) {
            if (child instanceof HTMLElement) {
                this.#collectHtmlIdEntries(child, document, parser, docUri, noLinkRanges, existingSlugs, entries);
            }
        }
    }
    #slugifier;
    constructor(entries, slugifier) {
        this.entries = entries;
        this.#slugifier = slugifier;
    }
    lookupByFragment(fragmentText) {
        const slug = this.#slugifier.fromFragment(fragmentText);
        return this.entries.find(entry => entry.slug.equals(slug));
    }
    lookupByHeading(text) {
        const slug = this.#slugifier.fromHeading(text);
        return this.entries.find(entry => entry.slug.equals(slug));
    }
    lookByLink(link) {
        return link.isAngleBracketLink ? this.lookupByHeading(link.fragment) : this.lookupByFragment(link.fragment);
    }
}
export class MdTableOfContentsProvider extends Disposable {
    #cache;
    #htmlIdResources = new ResourceMap();
    #parser;
    #workspace;
    #logger;
    constructor(parser, workspace, logger) {
        super();
        this.#parser = parser;
        this.#workspace = workspace;
        this.#logger = logger;
        this.#cache = this._register(new MdDocumentInfoCache(workspace, (doc, token) => {
            const includeHtmlIds = this.#htmlIdResources.has(getDocUri(doc));
            this.#logger.log(LogLevel.Debug, 'TableOfContentsProvider.create', { document: doc.uri, version: doc.version, includeHtmlIds });
            return TableOfContents.create(parser, doc, token, { includeHtmlIds });
        }));
    }
    get(resource, options) {
        if (options?.includeHtmlIds && !this.#htmlIdResources.has(resource)) {
            this.#htmlIdResources.set(resource, true);
            this.#cache.invalidate(resource);
        }
        return this.#cache.get(resource);
    }
    getForDocument(doc, options) {
        const uri = getDocUri(doc);
        if (options?.includeHtmlIds && !this.#htmlIdResources.has(uri)) {
            this.#htmlIdResources.set(uri, true);
            this.#cache.invalidate(uri);
        }
        return this.#cache.getForDocument(doc);
    }
    getForContainingDoc(doc, token, options) {
        return TableOfContents.createForContainingDoc(this.#parser, this.#workspace, doc, token, options);
    }
}
//# sourceMappingURL=tableOfContents.js.map