/*---------------------------------------------------------------------------------------------
 *  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 picomatch from 'picomatch';
import * as lsp from 'vscode-languageserver-protocol';
import { LogLevel } from '../logging.js';
import { HrefKind, MdLinkKind, ReferenceLinkMap } from '../types/documentLink.js';
import { translatePosition } from '../types/position.js';
import { modifyRange } from '../types/range.js';
import { getDocUri } from '../types/textDocument.js';
import { Disposable } from '../util/dispose.js';
import { Limiter } from '../util/limiter.js';
import { isSameResource, looksLikeMarkdownUri, parseLocationInfoFromFragment } from '../util/path.js';
import { ResourceMap } from '../util/resourceMap.js';
import { statLinkToMarkdownFile } from '../workspace.js';
/**
 * The severity at which diagnostics are reported
 */
export var DiagnosticLevel;
(function (DiagnosticLevel) {
    /** Don't report this diagnostic. */
    DiagnosticLevel["ignore"] = "ignore";
    /**
     * Report the diagnostic at a hint level.
     *
     * Hints will typically not be directly reported by editors, but may show up as unused spans.
     */
    DiagnosticLevel["hint"] = "hint";
    /** Report the diagnostic as a warning. */
    DiagnosticLevel["warning"] = "warning";
    /** Report the diagnostic as an error. */
    DiagnosticLevel["error"] = "error";
})(DiagnosticLevel || (DiagnosticLevel = {}));
function toSeverity(level) {
    switch (level) {
        case DiagnosticLevel.error: return lsp.DiagnosticSeverity.Error;
        case DiagnosticLevel.warning: return lsp.DiagnosticSeverity.Warning;
        case DiagnosticLevel.hint: return lsp.DiagnosticSeverity.Hint;
        case DiagnosticLevel.ignore: return undefined;
        case undefined: return undefined;
    }
}
/**
 * Error codes of Markdown diagnostics
 */
export var DiagnosticCode;
(function (DiagnosticCode) {
    /** The linked to reference does not exist. */
    DiagnosticCode["link_noSuchReferences"] = "link.no-such-reference";
    /** The linked to heading does not exist in the current file. */
    DiagnosticCode["link_noSuchHeaderInOwnFile"] = "link.no-such-header-in-own-file";
    /** The linked to local file does not exist. */
    DiagnosticCode["link_noSuchFile"] = "link.no-such-file";
    /** The linked to heading does not exist in the another file. */
    DiagnosticCode["link_noSuchHeaderInFile"] = "link.no-such-header-in-file";
    /** The link definition is not used anywhere. */
    DiagnosticCode["link_unusedDefinition"] = "link.unused-definition";
    /** The link definition is not used anywhere. */
    DiagnosticCode["link_duplicateDefinition"] = "link.duplicate-definition";
})(DiagnosticCode || (DiagnosticCode = {}));
/**
 * Map of file paths to markdown links to that file.
 */
class FileLinkMap {
    #filesToLinksMap = new ResourceMap();
    constructor(links) {
        for (const link of links) {
            if (link.href.kind !== HrefKind.Internal) {
                continue;
            }
            const existingFileEntry = this.#filesToLinksMap.get(link.href.path);
            const linkData = { source: link.source, fragment: link.href.fragment };
            if (existingFileEntry) {
                existingFileEntry.outgoingLinks.push(linkData);
            }
            else {
                this.#filesToLinksMap.set(link.href.path, { outgoingLinks: [linkData] });
            }
        }
    }
    get size() {
        return this.#filesToLinksMap.size;
    }
    entries() {
        return this.#filesToLinksMap.entries();
    }
}
export class DiagnosticComputer {
    #configuration;
    #workspace;
    #linkProvider;
    #tocProvider;
    #logger;
    constructor(configuration, workspace, linkProvider, tocProvider, logger) {
        this.#configuration = configuration;
        this.#workspace = workspace;
        this.#linkProvider = linkProvider;
        this.#tocProvider = tocProvider;
        this.#logger = logger;
    }
    async compute(doc, options, token) {
        this.#logger.log(LogLevel.Debug, 'DiagnosticComputer.compute', { document: doc.uri, version: doc.version });
        const { links, definitions } = await this.#linkProvider.getLinks(doc);
        const statCache = new ResourceMap();
        if (token.isCancellationRequested) {
            return { links, diagnostics: [], statCache };
        }
        // Current doc always implicitly exists
        statCache.set(getDocUri(doc), { exists: true });
        const diagnostics = (await Promise.all([
            this.#validateFileLinks(options, links, statCache, token),
            this.#validateFragmentLinks(doc, options, links, token),
            Array.from(this.#validateReferenceLinks(options, links, definitions)),
            Array.from(this.#validateUnusedLinkDefinitions(options, links)),
            Array.from(this.#validateDuplicateLinkDefinitions(options, links)),
        ])).flat();
        this.#logger.log(LogLevel.Trace, 'DiagnosticComputer.compute finished', { document: doc.uri, version: doc.version, diagnostics });
        return {
            links: links,
            statCache,
            diagnostics: diagnostics
        };
    }
    async #validateFragmentLinks(doc, options, links, token) {
        const severity = toSeverity(options.validateFragmentLinks);
        if (typeof severity === 'undefined') {
            return [];
        }
        const toc = await this.#tocProvider.getForDocument(doc, { includeHtmlIds: true });
        if (token.isCancellationRequested) {
            return [];
        }
        const diagnostics = [];
        for (const link of links) {
            if (link.href.kind === HrefKind.Internal
                && link.source.hrefText.startsWith('#')
                && isSameResource(link.href.path, getDocUri(doc))
                && link.href.fragment
                && !toc.lookByLink({ isAngleBracketLink: link.source.isAngleBracketLink, fragment: link.href.fragment })) {
                // Don't validate line number links
                if (parseLocationInfoFromFragment(link.href.fragment)) {
                    continue;
                }
                if (!this.#isIgnoredLink(options, link.source.hrefText)) {
                    diagnostics.push({
                        code: DiagnosticCode.link_noSuchHeaderInOwnFile,
                        message: l10n.t('No header found: \'{0}\'', link.href.fragment),
                        range: link.source.hrefRange,
                        severity,
                        data: {
                            hrefText: link.source.hrefText
                        }
                    });
                }
            }
        }
        return diagnostics;
    }
    *#validateReferenceLinks(options, links, definitions) {
        const severity = toSeverity(options.validateReferences);
        if (typeof severity === 'undefined') {
            return [];
        }
        for (const link of links) {
            if (link.href.kind === HrefKind.Reference && !definitions.lookup(link.href.ref)) {
                yield {
                    code: DiagnosticCode.link_noSuchReferences,
                    message: l10n.t('No link definition found: \'{0}\'', link.href.ref),
                    range: link.source.hrefRange,
                    severity,
                    data: {
                        ref: link.href.ref,
                    },
                };
            }
        }
    }
    *#validateUnusedLinkDefinitions(options, links) {
        const errorSeverity = toSeverity(options.validateUnusedLinkDefinitions);
        if (typeof errorSeverity === 'undefined') {
            return;
        }
        const usedRefs = new ReferenceLinkMap();
        for (const link of links) {
            if (link.kind === MdLinkKind.Link && link.href.kind === HrefKind.Reference) {
                usedRefs.set(link.href.ref, true);
            }
        }
        for (const link of links) {
            if (link.kind === MdLinkKind.Definition && !usedRefs.lookup(link.ref.text)) {
                yield {
                    code: DiagnosticCode.link_unusedDefinition,
                    message: l10n.t('Link definition is unused'),
                    range: link.source.range,
                    severity: errorSeverity,
                    tags: [
                        lsp.DiagnosticTag.Unnecessary,
                    ],
                    data: link
                };
            }
        }
    }
    *#validateDuplicateLinkDefinitions(options, links) {
        const errorSeverity = toSeverity(options.validateDuplicateLinkDefinitions);
        if (typeof errorSeverity === 'undefined') {
            return;
        }
        const definitionMultiMap = new Map();
        for (const link of links) {
            if (link.kind === MdLinkKind.Definition) {
                const existing = definitionMultiMap.get(link.ref.text);
                if (existing) {
                    existing.push(link);
                }
                else {
                    definitionMultiMap.set(link.ref.text, [link]);
                }
            }
        }
        for (const [ref, defs] of definitionMultiMap) {
            if (defs.length <= 1) {
                continue;
            }
            for (const duplicateDef of defs) {
                yield {
                    code: DiagnosticCode.link_duplicateDefinition,
                    message: l10n.t('Link definition for \'{0}\' already exists', ref),
                    range: duplicateDef.ref.range,
                    severity: errorSeverity,
                    relatedInformation: defs
                        .filter(x => x !== duplicateDef)
                        .map(def => lsp.DiagnosticRelatedInformation.create({ uri: def.source.resource.toString(), range: def.ref.range }, l10n.t('Link is also defined here'))),
                    data: duplicateDef
                };
            }
        }
    }
    async #validateFileLinks(options, links, statCache, token) {
        const pathErrorSeverity = toSeverity(options.validateFileLinks);
        if (typeof pathErrorSeverity === 'undefined') {
            return [];
        }
        const fragmentErrorSeverity = toSeverity(typeof options.validateMarkdownFileLinkFragments === 'undefined' ? options.validateFragmentLinks : options.validateMarkdownFileLinkFragments);
        // We've already validated our own fragment links in `validateOwnHeaderLinks`
        const linkSet = new FileLinkMap(links.filter(link => !link.source.hrefText.startsWith('#')));
        if (linkSet.size === 0) {
            return [];
        }
        const limiter = new Limiter(10);
        const diagnostics = [];
        await Promise.all(Array.from(linkSet.entries()).map(([path, { outgoingLinks: links }]) => {
            return limiter.queue(async () => {
                if (token.isCancellationRequested) {
                    return;
                }
                const resolvedHrefPath = await statLinkToMarkdownFile(this.#configuration, this.#workspace, path, statCache);
                if (token.isCancellationRequested) {
                    return;
                }
                if (!resolvedHrefPath) {
                    for (const link of links) {
                        if (!this.#isIgnoredLink(options, link.source.hrefPathText)) {
                            diagnostics.push({
                                code: DiagnosticCode.link_noSuchFile,
                                message: l10n.t('File does not exist at path: {0}', path.fsPath),
                                range: link.source.hrefRange,
                                severity: pathErrorSeverity,
                                data: {
                                    fsPath: path.fsPath,
                                    hrefText: link.source.hrefPathText,
                                }
                            });
                        }
                    }
                }
                else if (typeof fragmentErrorSeverity !== 'undefined' && this.#isMarkdownPath(resolvedHrefPath)) {
                    // Validate each of the links to headers in the file
                    const fragmentLinks = links.filter(x => x.fragment);
                    if (fragmentLinks.length) {
                        const toc = await this.#tocProvider.get(resolvedHrefPath, { includeHtmlIds: true });
                        if (token.isCancellationRequested) {
                            return;
                        }
                        for (const link of fragmentLinks) {
                            // Don't validate line number links
                            if (parseLocationInfoFromFragment(link.fragment)) {
                                continue;
                            }
                            if (!(toc && toc.lookByLink({ isAngleBracketLink: link.source.isAngleBracketLink, fragment: link.fragment })) && !this.#isIgnoredLink(options, link.source.hrefPathText) && !this.#isIgnoredLink(options, link.source.hrefText)) {
                                const range = (link.source.hrefFragmentRange && modifyRange(link.source.hrefFragmentRange, translatePosition(link.source.hrefFragmentRange.start, { characterDelta: -1 }), undefined)) ?? link.source.hrefRange;
                                diagnostics.push({
                                    code: DiagnosticCode.link_noSuchHeaderInFile,
                                    message: l10n.t('Header does not exist in file: {0}', link.fragment),
                                    range: range,
                                    severity: fragmentErrorSeverity,
                                    data: {
                                        fragment: link.fragment,
                                        hrefText: link.source.hrefText
                                    },
                                });
                            }
                        }
                    }
                }
            });
        }));
        return diagnostics;
    }
    #isMarkdownPath(resolvedHrefPath) {
        return this.#workspace.hasMarkdownDocument(resolvedHrefPath) || looksLikeMarkdownUri(this.#configuration, resolvedHrefPath);
    }
    #isIgnoredLink(options, link) {
        return options.ignoreLinks.some(glob => picomatch.isMatch(link, glob));
    }
}
class FileLinkState extends Disposable {
    #onDidChangeLinkedToFile = this._register(new lsp.Emitter);
    /**
     * Event fired with a list of document uri when one of the links in the document changes
     */
    onDidChangeLinkedToFile = this.#onDidChangeLinkedToFile.event;
    #linkedToFile = new ResourceMap();
    #workspace;
    #logger;
    constructor(workspace, logger) {
        super();
        this.#workspace = workspace;
        this.#logger = logger;
    }
    dispose() {
        super.dispose();
        for (const entry of this.#linkedToFile.values()) {
            entry.watcher.dispose();
        }
        this.#linkedToFile.clear();
    }
    /**
     * Set the known links in a markdown document, adding and removing file watchers as needed
     */
    updateLinksForDocument(document, links, statCache) {
        const linkedToResource = new Set(links
            .filter(link => link.href.kind === HrefKind.Internal)
            .map(link => ({ path: link.href.path, exists: !!(statCache.get(link.href.path)?.exists) })));
        // First decrement watcher counter for previous document state
        for (const entry of this.#linkedToFile.values()) {
            entry.documents.delete(document);
        }
        // Then create/update watchers for new document state
        for (const { path, exists } of linkedToResource) {
            let entry = this.#linkedToFile.get(path);
            if (!entry) {
                entry = {
                    watcher: this.#startWatching(path),
                    documents: new ResourceMap(),
                    exists
                };
                this.#linkedToFile.set(path, entry);
            }
            entry.documents.set(document, document);
        }
        // Finally clean up watchers for links that are no longer are referenced anywhere
        for (const [key, value] of this.#linkedToFile) {
            if (value.documents.size === 0) {
                value.watcher.dispose();
                this.#linkedToFile.delete(key);
            }
        }
    }
    deleteDocument(resource) {
        this.updateLinksForDocument(resource, [], new ResourceMap());
    }
    tryStatFileLink(link) {
        const entry = this.#linkedToFile.get(link);
        if (!entry) {
            return undefined;
        }
        return { exists: entry.exists };
    }
    #startWatching(path) {
        const watcher = this.#workspace.watchFile(path, { ignoreChange: true });
        const deleteReg = watcher.onDidDelete((resource) => this.#onLinkedResourceChanged(resource, false));
        const createReg = watcher.onDidCreate((resource) => this.#onLinkedResourceChanged(resource, true));
        return {
            dispose: () => {
                watcher.dispose();
                deleteReg.dispose();
                createReg.dispose();
            }
        };
    }
    #onLinkedResourceChanged(resource, exists) {
        this.#logger.log(LogLevel.Trace, 'FileLinkState.onLinkedResourceChanged', { resource, exists });
        const entry = this.#linkedToFile.get(resource);
        if (entry) {
            entry.exists = exists;
            this.#onDidChangeLinkedToFile.fire({
                changedResource: resource,
                linkingFiles: entry.documents.values(),
                exists,
            });
        }
    }
}
export class DiagnosticsManager extends Disposable {
    #computer;
    #linkWatcher;
    #onLinkedToFileChanged = this._register(new lsp.Emitter());
    onLinkedToFileChanged = this.#onLinkedToFileChanged.event;
    constructor(configuration, workspace, linkProvider, tocProvider, logger) {
        super();
        const linkWatcher = new FileLinkState(workspace, logger);
        this.#linkWatcher = this._register(linkWatcher);
        this._register(this.#linkWatcher.onDidChangeLinkedToFile(e => {
            logger.log(LogLevel.Trace, 'DiagnosticsManager.onDidChangeLinkedToFile', { resource: e.changedResource });
            this.#onLinkedToFileChanged.fire({
                changedResource: e.changedResource,
                linkingResources: Array.from(e.linkingFiles),
            });
        }));
        const stateCachedWorkspace = new Proxy(workspace, {
            get(target, p, receiver) {
                if (p !== 'stat') {
                    const value = Reflect.get(target, p, receiver);
                    return typeof value === 'function' ? value.bind(workspace) : value;
                }
                return async function (resource) {
                    const stat = linkWatcher.tryStatFileLink(resource);
                    if (stat) {
                        if (stat.exists) {
                            return { isDirectory: false };
                        }
                        else {
                            return undefined;
                        }
                    }
                    return workspace.stat.call(this === receiver ? target : this, resource);
                };
            },
        });
        this.#computer = new DiagnosticComputer(configuration, stateCachedWorkspace, linkProvider, tocProvider, logger);
        this._register(workspace.onDidDeleteMarkdownDocument(uri => {
            this.#linkWatcher.deleteDocument(uri);
        }));
    }
    async computeDiagnostics(doc, options, token) {
        const results = await this.#computer.compute(doc, options, token);
        if (token.isCancellationRequested) {
            return [];
        }
        this.#linkWatcher.updateLinksForDocument(getDocUri(doc), results.links, results.statCache);
        return results.diagnostics;
    }
    disposeDocumentResources(uri) {
        this.#linkWatcher.deleteDocument(uri);
    }
}
//# sourceMappingURL=diagnostics.js.map