/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/
import * as l10n from '@vscode/l10n';
import { dirname, extname, resolve } from 'node:path';
import * as lsp from 'vscode-languageserver-protocol';
import { isExcludedPath } from '../config.js';
import { translatePosition } from '../types/position.js';
import { getDocUri, getLine } from '../types/textDocument.js';
import { htmlTagPathAttrs } from '../util/html.js';
import * as mdBuilder from '../util/mdBuilder.js';
import { escapeForAngleBracketLink, hasBalancedParens } from '../util/mdLinks.js';
import { MediaType, getMediaPreviewType } from '../util/media.js';
import { computeRelativePath, isSameResource, looksLikeMarkdownFilePath } from '../util/path.js';
import { Schemes } from '../util/schemes.js';
import { r } from '../util/string.js';
import { URI, Utils } from '../util/vscodeUri.js';
import { getWorkspaceFolder, openLinkToMarkdownFile } from '../workspace.js';
import { MdWorkspaceInfoCache } from '../workspaceCache.js';
var CompletionContextKind;
(function (CompletionContextKind) {
    /** `[...](|)` */
    CompletionContextKind[CompletionContextKind["Link"] = 0] = "Link";
    /** `[...][|]` */
    CompletionContextKind[CompletionContextKind["ReferenceLink"] = 1] = "ReferenceLink";
    /** `[]: |` */
    CompletionContextKind[CompletionContextKind["LinkDefinition"] = 2] = "LinkDefinition";
    /** <img src="|"> */
    CompletionContextKind[CompletionContextKind["HtmlAttribute"] = 3] = "HtmlAttribute";
})(CompletionContextKind || (CompletionContextKind = {}));
function tryDecodeUriComponent(str) {
    try {
        return decodeURIComponent(str);
    }
    catch {
        return str;
    }
}
/**
 * Controls if header completions for other files in the workspace be returned.
 */
export var IncludeWorkspaceHeaderCompletions;
(function (IncludeWorkspaceHeaderCompletions) {
    /**
     * Never return workspace header completions.
     */
    IncludeWorkspaceHeaderCompletions["never"] = "never";
    /**
     * Return workspace header completions after `##` is typed.
     *
     * This lets the user signal
     */
    IncludeWorkspaceHeaderCompletions["onDoubleHash"] = "onDoubleHash";
    /**
     * Return workspace header completions after either a single `#` is typed or after `##`
     *
     * For a single hash, this means the workspace header completions will be returned along side the current file header completions.
     */
    IncludeWorkspaceHeaderCompletions["onSingleOrDoubleHash"] = "onSingleOrDoubleHash";
})(IncludeWorkspaceHeaderCompletions || (IncludeWorkspaceHeaderCompletions = {}));
const sortTexts = Object.freeze({
    localHeader: '1',
    workspaceHeader: '2',
});
/**
 * Adds path completions in markdown files.
 */
export class MdPathCompletionProvider {
    #configuration;
    #workspace;
    #linkProvider;
    #tocProvider;
    #workspaceTocCache;
    constructor(configuration, workspace, linkProvider, tocProvider) {
        this.#configuration = configuration;
        this.#workspace = workspace;
        this.#linkProvider = linkProvider;
        this.#tocProvider = tocProvider;
        this.#workspaceTocCache = new MdWorkspaceInfoCache(workspace, (doc) => tocProvider.getForDocument(doc));
    }
    async provideCompletionItems(document, position, context, token) {
        const pathContext = this.#getPathCompletionContext(document, position);
        if (!pathContext) {
            return [];
        }
        const items = [];
        for await (const item of this.#provideCompletionItems(document, position, pathContext, context, token)) {
            items.push(item);
        }
        return items;
    }
    async *#provideCompletionItems(document, position, context, options, token) {
        switch (context.kind) {
            case CompletionContextKind.ReferenceLink: {
                yield* this.#provideReferenceSuggestions(document, position, context, token);
                return;
            }
            case CompletionContextKind.LinkDefinition:
            case CompletionContextKind.Link:
            case CompletionContextKind.HtmlAttribute: {
                if (!context.isImageLink) {
                    if ((context.linkPrefix.startsWith('#') && options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onSingleOrDoubleHash) ||
                        (context.linkPrefix.startsWith('##') && (options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onDoubleHash || options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onSingleOrDoubleHash))) {
                        const insertRange = lsp.Range.create(context.linkTextStartPosition, position);
                        yield* this.#provideWorkspaceHeaderSuggestions(document, position, context, insertRange, token);
                        return;
                    }
                    const isAnchorInCurrentDoc = context.anchorInfo && context.anchorInfo.beforeAnchor.length === 0;
                    // Add anchor #links in current doc
                    if (context.linkPrefix.length === 0 || isAnchorInCurrentDoc) {
                        const insertRange = lsp.Range.create(context.linkTextStartPosition, position);
                        yield* this.#provideHeaderSuggestions(document, position, context, insertRange, token, true);
                    }
                    if (token.isCancellationRequested) {
                        return;
                    }
                    if (!isAnchorInCurrentDoc) {
                        if (context.anchorInfo) { // Anchor to a different document
                            const rawUri = this.#resolveReference(document, context.anchorInfo.beforeAnchor);
                            if (rawUri) {
                                const otherDoc = await openLinkToMarkdownFile(this.#configuration, this.#workspace, rawUri);
                                if (token.isCancellationRequested) {
                                    return;
                                }
                                if (otherDoc) {
                                    const anchorStartPosition = translatePosition(position, { characterDelta: -(context.anchorInfo.anchorPrefix.length + 1) });
                                    const range = lsp.Range.create(anchorStartPosition, position);
                                    yield* this.#provideHeaderSuggestions(otherDoc, position, context, range, token, false);
                                }
                            }
                        }
                        else { // Normal path suggestions
                            yield* this.#providePathSuggestions(document, position, context, token);
                        }
                    }
                }
                else {
                    // Image links only get path suggestions, not header/anchor completions
                    if (!context.anchorInfo && !context.linkPrefix.startsWith('#')) {
                        yield* this.#providePathSuggestions(document, position, context, token);
                    }
                }
                return;
            }
        }
    }
    /// [...](...|
    #linkStartPattern = new RegExp(
    // text
    r `\[` +
        /**/ r `(?:` +
        /*****/ r `[^\[\]\\]|` + // Non-bracket chars, or...
        /*****/ r `\\.|` + // Escaped char, or...
        /*****/ r `\[[^\[\]]*\]` + // Matched bracket pair
        /**/ r `)*` +
        r `\]` +
        // Destination start
        r `\(\s*(<[^\>\)]*|[^\s\(\)]*)` +
        r `$` // Must match cursor position
    );
    /// [...][...|
    #referenceLinkStartPattern = /\[([^\]]*?)\]\[\s*([^\s\(\)]*)$/;
    /// [ref...|
    #referenceLinkShortHandPrefix = /(?<![\]\)])\[([^\s\(\)]*)$/;
    /// [id]: |
    #definitionPattern = /^\s*\[(?!\^)(?:\\\]|\\\[|[^\]\[\n])+]:\s*([^\s]*)$/m;
    /// [id]: |
    #htmlAttributeStartPattern = /\<(?<tag>\w+)([^>]*)\s(?<attr>\w+)=['"](?<link>[^'"]*)$/m;
    #getPathCompletionContext(document, position) {
        const line = getLine(document, position.line);
        const linePrefixText = line.slice(0, position.character);
        const lineSuffixText = line.slice(position.character);
        const htmlAttributePrefixMatch = linePrefixText.match(this.#htmlAttributeStartPattern);
        if (htmlAttributePrefixMatch?.groups) {
            const validPathAttrs = htmlTagPathAttrs.get(htmlAttributePrefixMatch.groups.tag.toUpperCase());
            if (!validPathAttrs || !validPathAttrs.some(attr => attr === htmlAttributePrefixMatch.groups.attr.toLowerCase())) {
                return undefined;
            }
            const prefix = htmlAttributePrefixMatch.groups.link;
            if (this.#refLooksLikeUrl(prefix)) {
                return undefined;
            }
            const suffix = lineSuffixText.match(/^[^\s'"]*/);
            return this.#createCompletionContext(CompletionContextKind.HtmlAttribute, position, prefix, suffix?.[0] ?? '', false);
        }
        const linkPrefixMatch = linePrefixText.match(this.#linkStartPattern);
        if (linkPrefixMatch) {
            const isAngleBracketLink = linkPrefixMatch[1].startsWith('<');
            const prefix = linkPrefixMatch[1].slice(isAngleBracketLink ? 1 : 0);
            if (this.#refLooksLikeUrl(prefix)) {
                return undefined;
            }
            const isImageLink = linePrefixText.charAt(linkPrefixMatch.index - 1) === '!';
            const suffix = lineSuffixText.match(/^[^\)\s][^\)\s\>]*/);
            return this.#createCompletionContext(CompletionContextKind.Link, position, prefix, suffix?.[0] ?? '', isAngleBracketLink, isImageLink);
        }
        const definitionLinkPrefixMatch = linePrefixText.match(this.#definitionPattern);
        if (definitionLinkPrefixMatch) {
            const isAngleBracketLink = definitionLinkPrefixMatch[1].startsWith('<');
            const prefix = definitionLinkPrefixMatch[1].slice(isAngleBracketLink ? 1 : 0);
            if (this.#refLooksLikeUrl(prefix)) {
                return undefined;
            }
            const suffix = lineSuffixText.match(/^[^\s]*/);
            return this.#createCompletionContext(CompletionContextKind.LinkDefinition, position, prefix, suffix?.[0] ?? '', isAngleBracketLink);
        }
        const fullReferenceLinkPrefixMatch = linePrefixText.match(this.#referenceLinkStartPattern);
        if (fullReferenceLinkPrefixMatch) {
            const prefix = fullReferenceLinkPrefixMatch[2];
            const suffix = lineSuffixText.match(/^[^\]\s]*/);
            return {
                kind: CompletionContextKind.ReferenceLink,
                linkPrefix: prefix,
                linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }),
                linkSuffix: suffix ? suffix[0] : '',
            };
        }
        const shorthandReferenceLinkPrefixMatch = linePrefixText.match(this.#referenceLinkShortHandPrefix);
        if (shorthandReferenceLinkPrefixMatch) {
            const prefix = shorthandReferenceLinkPrefixMatch[1];
            const suffix = lineSuffixText.match(/^[^\]\s]*/);
            return {
                kind: CompletionContextKind.ReferenceLink,
                linkPrefix: prefix,
                linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }),
                linkSuffix: suffix ? suffix[0] : '',
            };
        }
        return undefined;
    }
    #createCompletionContext(kind, position, prefix, suffix, isAngleBracketPath, isImageLink) {
        return {
            kind,
            linkPrefix: prefix,
            linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }),
            linkSuffix: suffix,
            anchorInfo: this.#getAnchorContext(prefix),
            isAngleBracketPath,
            isImageLink,
        };
    }
    /**
     * Check if {@param ref} looks like a 'http:' style url.
     */
    #refLooksLikeUrl(prefix) {
        return /^\s*[\w\d\-]+:/.test(prefix);
    }
    #getAnchorContext(prefix) {
        const anchorMatch = prefix.match(/^([^#]*)#([^#]*)$/);
        if (!anchorMatch) {
            return undefined;
        }
        return {
            beforeAnchor: anchorMatch[1],
            anchorPrefix: anchorMatch[2],
        };
    }
    async *#provideReferenceSuggestions(document, position, context, token) {
        const insertionRange = lsp.Range.create(context.linkTextStartPosition, position);
        const replacementRange = lsp.Range.create(insertionRange.start, translatePosition(position, { characterDelta: context.linkSuffix.length }));
        const { definitions } = await this.#linkProvider.getLinks(document);
        if (token.isCancellationRequested) {
            return;
        }
        for (const def of definitions) {
            yield {
                kind: lsp.CompletionItemKind.Reference,
                label: def.ref.text,
                detail: l10n.t(`Reference link '{0}'`, def.ref.text),
                textEdit: {
                    newText: def.ref.text,
                    insert: insertionRange,
                    replace: replacementRange,
                },
            };
        }
    }
    async *#provideHeaderSuggestions(document, position, context, insertionRange, token, isCurrentDoc) {
        const toc = await this.#tocProvider.getForContainingDoc(document, token, isCurrentDoc ? { includeHtmlIds: true } : undefined);
        if (token.isCancellationRequested) {
            return;
        }
        const replacementRange = lsp.Range.create(insertionRange.start, translatePosition(position, { characterDelta: context.linkSuffix.length }));
        for (const entry of toc.entries) {
            const completionItem = this.#createTocCompletion(entry, insertionRange, replacementRange, undefined);
            completionItem.labelDetails = {};
            yield completionItem;
        }
    }
    #createTocCompletion(entry, insertionRange, replacementRange, filePathOnSystem) {
        if (entry.kind === 'html-id') {
            return this.#createHtmlIdCompletion(entry, insertionRange, replacementRange, filePathOnSystem);
        }
        else {
            return this.#createHeaderCompletion(entry, insertionRange, replacementRange, filePathOnSystem);
        }
    }
    #createHeaderCompletion(entry, insertionRange, replacementRange, filePathOnSystem) {
        const label = '#' + decodeURIComponent(entry.slug.value);
        const newText = (filePathOnSystem ?? '') + '#' + decodeURIComponent(entry.slug.value);
        return {
            kind: lsp.CompletionItemKind.Reference,
            label,
            detail: l10n.t(`Link to '{0}'`, '#'.repeat(entry.level) + ' ' + entry.text),
            textEdit: {
                newText,
                insert: insertionRange,
                replace: replacementRange,
            },
        };
    }
    #createHtmlIdCompletion(entry, insertionRange, replacementRange, filePathOnSystem) {
        const label = '#' + decodeURIComponent(entry.slug.value);
        const newText = (filePathOnSystem ?? '') + '#' + decodeURIComponent(entry.slug.value);
        return {
            kind: lsp.CompletionItemKind.Value,
            label,
            detail: l10n.t(`Link to HTML id '{0}'`, entry.text),
            textEdit: {
                newText,
                insert: insertionRange,
                replace: replacementRange,
            },
        };
    }
    /**
     * Suggestions for headers across  all md files in the workspace
     */
    async *#provideWorkspaceHeaderSuggestions(document, position, context, insertionRange, token) {
        const tocs = await this.#workspaceTocCache.entries();
        if (token.isCancellationRequested) {
            return;
        }
        const replacementRange = lsp.Range.create(insertionRange.start, translatePosition(position, { characterDelta: context.linkSuffix.length }));
        for (const [toDoc, toc] of tocs) {
            const isHeaderInCurrentDocument = isSameResource(toDoc, getDocUri(document));
            const rawPath = isHeaderInCurrentDocument ? '' : computeRelativePath(getDocUri(document), toDoc);
            if (typeof rawPath === 'undefined') {
                continue;
            }
            // For the current document, use the TOC that includes HTML id entries
            const effectiveToc = isHeaderInCurrentDocument
                ? await this.#tocProvider.getForDocument(document, { includeHtmlIds: true })
                : toc;
            const normalizedPath = this.#normalizeFileNameCompletion(rawPath);
            const path = this.#getPathInsertText(context, normalizedPath);
            for (const entry of effectiveToc.entries) {
                const completionItem = this.#createTocCompletion(entry, insertionRange, replacementRange, path);
                completionItem.filterText = '#' + completionItem.label;
                completionItem.sortText = isHeaderInCurrentDocument ? sortTexts.localHeader : sortTexts.workspaceHeader;
                if (!isHeaderInCurrentDocument && path) {
                    completionItem.detail = l10n.t(`Link to '# {0}' in '{1}'`, entry.text, path);
                    completionItem.labelDetails = { description: path };
                }
                yield completionItem;
            }
        }
    }
    async *#providePathSuggestions(document, position, context, token) {
        const valueBeforeLastSlash = context.linkPrefix.substring(0, context.linkPrefix.lastIndexOf('/') + 1); // keep the last slash
        const parentDir = this.#resolveReference(document, valueBeforeLastSlash ? tryDecodeUriComponent(valueBeforeLastSlash) : '.');
        if (!parentDir) {
            return;
        }
        const pathSegmentStart = translatePosition(position, { characterDelta: valueBeforeLastSlash.length - context.linkPrefix.length });
        const insertRange = lsp.Range.create(pathSegmentStart, position);
        const pathSegmentEnd = translatePosition(position, { characterDelta: context.linkSuffix.length });
        const replacementRange = lsp.Range.create(pathSegmentStart, pathSegmentEnd);
        let dirInfo;
        try {
            dirInfo = await this.#workspace.readDirectory(parentDir);
        }
        catch {
            return;
        }
        if (token.isCancellationRequested) {
            return;
        }
        // eslint-disable-next-line prefer-const
        for (let [name, type] of dirInfo) {
            const uri = Utils.joinPath(parentDir, name);
            if (isExcludedPath(this.#configuration, uri)) {
                continue;
            }
            if (!type.isDirectory) {
                name = this.#normalizeFileNameCompletion(name);
            }
            const isDir = type.isDirectory;
            const newText = this.#getPathInsertText(context, name) + (isDir ? '/' : '');
            const label = isDir ? name + '/' : name;
            yield {
                label,
                kind: isDir ? lsp.CompletionItemKind.Folder : lsp.CompletionItemKind.File,
                detail: l10n.t(`Link to '{0}'`, label),
                documentation: this.#getPathDocumentation(uri, type),
                textEdit: {
                    newText,
                    insert: insertRange,
                    replace: replacementRange,
                },
                command: isDir ? { command: 'editor.action.triggerSuggest', title: '' } : undefined,
            };
        }
    }
    #getPathDocumentation(uri, stat) {
        let documentation = stat.isDirectory
            ? mdBuilder.inlineCode(uri.path + '/') // TODO: support links to folders too
            : mdBuilder.codeLink(uri.path, uri);
        if (!stat.isDirectory) {
            const maxMediaWidth = 300;
            switch (getMediaPreviewType(uri)) {
                case MediaType.Image: {
                    documentation += `\n\n${mdBuilder.imageLink(uri, 'Linked image', maxMediaWidth)}`;
                    break;
                }
                case MediaType.Video: {
                    documentation += `\n\n${mdBuilder.video(uri, maxMediaWidth)}`;
                    break;
                }
            }
        }
        return {
            kind: lsp.MarkupKind.Markdown,
            value: documentation,
        };
    }
    #getPathInsertText(context, name) {
        // If the  path has a literal `%` in it, we need encode it to prevent
        // it being incorrectly decoded
        name = name.replaceAll('%', '%25');
        if (context.kind === CompletionContextKind.HtmlAttribute) {
            return name
                .replaceAll(`"`, '&quot;')
                .replaceAll(`'`, '&apos;');
        }
        if (context.isAngleBracketPath) {
            return escapeForAngleBracketLink(name);
        }
        if (!hasBalancedParens(name)) {
            name = name.replace(/([()])/g, '\\$1'); // CodeQL [SM02383] This code is for editing text, not sanitizing rendered markdown
        }
        return name.replaceAll(' ', '%20');
    }
    #normalizeFileNameCompletion(name) {
        if (this.#configuration.preferredMdPathExtensionStyle === 'removeExtension') {
            if (looksLikeMarkdownFilePath(this.#configuration, name)) {
                const ext = extname(name);
                name = name.slice(0, -ext.length);
            }
        }
        return name;
    }
    #resolveReference(document, ref) {
        const docUri = this.#getFileUriOfTextDocument(document);
        if (ref.startsWith('/')) {
            const workspaceFolder = getWorkspaceFolder(this.#workspace, docUri);
            if (workspaceFolder) {
                return Utils.joinPath(workspaceFolder, ref);
            }
            else {
                return this.#resolvePath(docUri, ref.slice(1));
            }
        }
        return this.#resolvePath(docUri, ref);
    }
    #resolvePath(root, ref) {
        try {
            if (root.scheme === Schemes.file) {
                return URI.file(resolve(dirname(root.fsPath), ref));
            }
            else {
                return root.with({
                    path: resolve(dirname(root.path), ref),
                });
            }
        }
        catch {
            return undefined;
        }
    }
    #getFileUriOfTextDocument(document) {
        return this.#workspace.getContainingDocument?.(getDocUri(document))?.uri ?? getDocUri(document);
    }
}
//# sourceMappingURL=pathCompletions.js.map