// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/* eslint-disable @devtools/no-imperative-dom-api, @devtools/no-lit-render-outside-of-view */

import * as Common from '../../../../core/common/common.js';
import * as Host from '../../../../core/host/host.js';
import * as i18n from '../../../../core/i18n/i18n.js';
import * as Platform from '../../../../core/platform/platform.js';
import * as SDK from '../../../../core/sdk/sdk.js';
import type * as Protocol from '../../../../generated/protocol.js';
import * as Bindings from '../../../../models/bindings/bindings.js';
import * as Breakpoints from '../../../../models/breakpoints/breakpoints.js';
import type * as StackTrace from '../../../../models/stack_trace/stack_trace.js';
import * as TextUtils from '../../../../models/text_utils/text_utils.js';
import type * as Trace from '../../../../models/trace/trace.js';
import * as Workspace from '../../../../models/workspace/workspace.js';
import * as UIHelpers from '../../../helpers/helpers.js';
import {Directives, html, type LitTemplate, render, type TemplateResult} from '../../../lit/lit.js';
import * as VisualLogging from '../../../visual_logging/visual_logging.js';
import * as UI from '../../legacy.js';

const {ref, ifDefined, classMap} = Directives;

const UIStrings = {
  /**
   * @description Text in Linkifier
   */
  unknown: '(unknown)',
  /**
   * @description Text short for automatic
   */
  auto: 'auto',
  /**
   * @description Text in Linkifier
   * @example {Sources panel} PH1
   */
  revealInS: 'Reveal in {PH1}',
  /**
   * @description Text for revealing an item in its destination
   */
  reveal: 'Reveal',
  /**
   * @description A context menu item in the Linkifier
   * @example {Extension} PH1
   */
  openUsingS: 'Open using {PH1}',
  /**
   * @description The name of a setting which controls how links are handled in the UI. 'Handling'
   * refers to the ability of extensions to DevTools to be able to intercept link clicks so that they
   * can react to them.
   */
  linkHandling: 'Link handling:',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/utils/Linkifier.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const instances = new Set<Linkifier>();

let decorator: LinkDecorator|null = null;

const anchorsByUISourceCode = new WeakMap<Workspace.UISourceCode.UISourceCode, Set<Element>>();

const infoByAnchor = new WeakMap<Node, LinkInfo>();

const textByAnchor = new WeakMap<Node, string>();

// Maps a DevTools Extension origin to a particular LinkHandler.
const linkHandlers = new Map<string, LinkHandlerRegistration>();

let linkHandlerSettingInstance: Common.Settings.Setting<string>;

export class Linkifier extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements SDK.TargetManager.Observer {
  private readonly maxLength: number;
  private readonly anchorsByTarget = new Map<SDK.Target.Target, Element[]>();
  private readonly locationPoolByTarget = new Map<SDK.Target.Target, Bindings.LiveLocation.LiveLocationPool>();
  private useLinkDecorator: boolean;
  readonly #anchorUpdaters: WeakMap<Element, (this: Linkifier, anchor: HTMLElement) => void>;

  constructor(maxLengthForDisplayedURLs?: number, useLinkDecorator?: boolean) {
    super();
    this.maxLength = maxLengthForDisplayedURLs || UI.UIUtils.MaxLengthForDisplayedURLs;
    this.useLinkDecorator = Boolean(useLinkDecorator);
    this.#anchorUpdaters = new WeakMap();
    instances.add(this);
    SDK.TargetManager.TargetManager.instance().observeTargets(this);
    Workspace.Workspace.WorkspaceImpl.instance().addEventListener(
        Workspace.Workspace.Events.WorkingCopyChanged, this.#onWorkingCopyChangedOrCommitted, this);
    Workspace.Workspace.WorkspaceImpl.instance().addEventListener(
        Workspace.Workspace.Events.WorkingCopyCommitted, this.#onWorkingCopyChangedOrCommitted, this);
  }

  #onWorkingCopyChangedOrCommitted({
    data: {uiSourceCode}
  }: Common.EventTarget.EventTargetEvent<{uiSourceCode: Workspace.UISourceCode.UISourceCode}>): void {
    const anchors = anchorsByUISourceCode.get(uiSourceCode);
    if (!anchors) {
      return;
    }
    for (const anchor of anchors) {
      const updater = this.#anchorUpdaters.get(anchor);
      if (!updater) {
        continue;
      }
      updater.call(this, anchor as HTMLElement);
    }
  }

  static setLinkDecorator(linkDecorator: LinkDecorator): void {
    console.assert(!decorator, 'Cannot re-register link decorator.');
    decorator = linkDecorator;
    linkDecorator.addEventListener(LinkDecorator.Events.LINK_ICON_CHANGED, onLinkIconChanged);
    for (const linkifier of instances) {
      linkifier.updateAllAnchorDecorations();
    }

    function onLinkIconChanged(event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>): void {
      const uiSourceCode = event.data;
      const links = anchorsByUISourceCode.get(uiSourceCode) || [];
      for (const link of links) {
        Linkifier.updateLinkDecorations(link);
      }
    }
  }

  private updateAllAnchorDecorations(): void {
    for (const anchors of this.anchorsByTarget.values()) {
      for (const anchor of anchors) {
        Linkifier.updateLinkDecorations(anchor);
      }
    }
  }

  private static bindUILocation(anchor: Element, uiLocation: Workspace.UISourceCode.UILocation): void {
    const linkInfo = Linkifier.linkInfo(anchor);
    if (!linkInfo) {
      return;
    }
    linkInfo.uiLocation = uiLocation;
    if (!uiLocation) {
      return;
    }
    const uiSourceCode = uiLocation.uiSourceCode;
    let sourceCodeAnchors = anchorsByUISourceCode.get(uiSourceCode);
    if (!sourceCodeAnchors) {
      sourceCodeAnchors = new Set();
      anchorsByUISourceCode.set(uiSourceCode, sourceCodeAnchors);
    }
    sourceCodeAnchors.add(anchor);
  }

  static bindUILocationForTest(anchor: Element, uiLocation: Workspace.UISourceCode.UILocation): void {
    Linkifier.bindUILocation(anchor, uiLocation);
  }

  private static unbindUILocation(anchor: Element): void {
    const info = Linkifier.linkInfo(anchor);
    if (!info?.uiLocation) {
      return;
    }

    const uiSourceCode = info.uiLocation.uiSourceCode;
    info.uiLocation = null;
    const sourceCodeAnchors = anchorsByUISourceCode.get(uiSourceCode);
    if (sourceCodeAnchors) {
      sourceCodeAnchors.delete(anchor);
    }
  }

  /**
   * When we link to a breakpoint condition, we need to stash the BreakpointLocation as the revealable
   * in the LinkInfo.
   */
  private static bindBreakpoint(anchor: Element, uiLocation: Workspace.UISourceCode.UILocation): void {
    const info = Linkifier.linkInfo(anchor);
    if (!info) {
      return;
    }

    const breakpoint = Breakpoints.BreakpointManager.BreakpointManager.instance().findBreakpoint(uiLocation);
    if (breakpoint) {
      info.revealable = breakpoint;
    }
  }

  /**
   * When we link to a breakpoint condition, we store the BreakpointLocation in the revealable.
   * Clear it when the LiveLocation updates.
   */
  private static unbindBreakpoint(anchor: Element): void {
    const info = Linkifier.linkInfo(anchor);
    if (info?.revealable) {
      info.revealable = null;
    }
  }

  targetAdded(target: SDK.Target.Target): void {
    this.anchorsByTarget.set(target, []);
    this.locationPoolByTarget.set(target, new Bindings.LiveLocation.LiveLocationPool());
  }

  targetRemoved(target: SDK.Target.Target): void {
    const locationPool = this.locationPoolByTarget.get(target);
    this.locationPoolByTarget.delete(target);
    if (!locationPool) {
      return;
    }
    locationPool.disposeAll();
    const anchors = (this.anchorsByTarget.get(target) as HTMLElement[] | null);
    if (!anchors) {
      return;
    }
    this.anchorsByTarget.delete(target);
    for (const anchor of anchors) {
      const info = Linkifier.linkInfo(anchor);
      if (!info) {
        continue;
      }
      info.liveLocation = null;
      Linkifier.unbindUILocation(anchor);
      const fallback = info.fallback;
      if (fallback) {
        anchor.replaceWith(fallback);
      }
    }
  }

  maybeLinkifyScriptLocation(
      target: SDK.Target.Target|null, scriptId: Protocol.Runtime.ScriptId|null,
      sourceURL: Platform.DevToolsPath.UrlString, lineNumber: number|undefined, options?: LinkifyOptions): HTMLElement
      |null {
    let fallbackAnchor: HTMLElement|null = null;
    const linkifyURLOptions = {
      lineNumber,
      maxLength: options?.maxLength ?? this.maxLength,
      columnNumber: options?.columnNumber,
      showColumnNumber: Boolean(options?.showColumnNumber),
      className: options?.className,
      tabStop: options?.tabStop,
      inlineFrameIndex: options?.inlineFrameIndex ?? 0,
      userMetric: options?.userMetric,
      jslogContext: options?.jslogContext || 'script-location',
      omitOrigin: options?.omitOrigin,
    } satisfies LinkifyURLOptions;
    const {columnNumber, className = ''} = linkifyURLOptions;
    if (sourceURL) {
      fallbackAnchor = Linkifier.linkifyURL(sourceURL, linkifyURLOptions);
    }
    if (!target || target.isDisposed()) {
      return fallbackAnchor;
    }
    const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel);
    if (!debuggerModel) {
      return fallbackAnchor;
    }

    // Prefer createRawLocationByScriptId() here, since it will always produce a correct
    // link, since the script ID is unique. Only fall back to createRawLocationByURL()
    // when all we have is an URL, which is not guaranteed to be unique.
    const rawLocation = scriptId ? debuggerModel.createRawLocationByScriptId(
                                       scriptId, lineNumber || 0, columnNumber, linkifyURLOptions.inlineFrameIndex) :
                                   debuggerModel.createRawLocationByURL(
                                       sourceURL, lineNumber || 0, columnNumber, linkifyURLOptions.inlineFrameIndex);
    if (!rawLocation) {
      return fallbackAnchor;
    }

    const createLinkOptions: CreateLinkOptions = {
      tabStop: options?.tabStop,
      jslogContext: 'script-location',
    };
    const {link, linkInfo} = Linkifier.createLink(
        fallbackAnchor?.textContent ? fallbackAnchor.textContent : '', className, createLinkOptions);
    linkInfo.enableDecorator = this.useLinkDecorator;
    linkInfo.fallback = fallbackAnchor;
    linkInfo.userMetric = options?.userMetric;

    const pool = this.locationPoolByTarget.get(rawLocation.debuggerModel.target());
    if (!pool) {
      return fallbackAnchor;
    }

    const linkDisplayOptions: LinkDisplayOptions = {
      showColumnNumber: linkifyURLOptions.showColumnNumber ?? false,
      maxLength: linkifyURLOptions.maxLength,
      revealBreakpoint: options?.revealBreakpoint,
    };

    const updateDelegate = async(liveLocation: Bindings.LiveLocation.LiveLocation): Promise<void> => {
      await this.updateAnchor(link, linkDisplayOptions, liveLocation);
      this.dispatchEventToListeners(Events.LIVE_LOCATION_UPDATED, liveLocation);
    };
    void Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance()
        .createLiveLocation(rawLocation, updateDelegate.bind(this), pool)
        .then(liveLocation => {
          if (liveLocation) {
            linkInfo.liveLocation = liveLocation;
          }
        });

    const anchors = (this.anchorsByTarget.get(rawLocation.debuggerModel.target()) as Element[]);
    anchors.push(link);
    return link;
  }

  linkifyScriptLocation(
      target: SDK.Target.Target|null, scriptId: Protocol.Runtime.ScriptId|null,
      sourceURL: Platform.DevToolsPath.UrlString, lineNumber: number|undefined, options?: LinkifyOptions): HTMLElement {
    const scriptLink = this.maybeLinkifyScriptLocation(target, scriptId, sourceURL, lineNumber, options);
    const linkifyURLOptions: LinkifyURLOptions = {
      lineNumber,
      maxLength: this.maxLength,
      className: options?.className,
      columnNumber: options?.columnNumber,
      showColumnNumber: Boolean(options?.showColumnNumber),
      inlineFrameIndex: options?.inlineFrameIndex ?? 0,
      tabStop: options?.tabStop,
      userMetric: options?.userMetric,
      jslogContext: options?.jslogContext || 'script-source-url',
    };

    return scriptLink || Linkifier.linkifyURL(sourceURL, linkifyURLOptions);
  }

  linkifyRawLocation(
      rawLocation: SDK.DebuggerModel.Location, fallbackUrl: Platform.DevToolsPath.UrlString, className?: string,
      options?: LinkifyOptions): HTMLElement {
    return this.linkifyScriptLocation(
        rawLocation.debuggerModel.target(), rawLocation.scriptId, fallbackUrl, rawLocation.lineNumber, {
          columnNumber: rawLocation.columnNumber,
          className,
          inlineFrameIndex: rawLocation.inlineFrameIndex,
          tabStop: options?.tabStop,
        });
  }

  maybeLinkifyConsoleCallFrame(
      target: SDK.Target.Target|null, callFrame: Protocol.Runtime.CallFrame|Trace.Types.Events.CallFrame,
      options?: LinkifyOptions): HTMLElement|null {
    const linkifyOptions: LinkifyOptions = {
      ...options,
      columnNumber: callFrame.columnNumber,
      inlineFrameIndex: options?.inlineFrameIndex ?? 0,
    };
    return this.maybeLinkifyScriptLocation(
        target, String(callFrame.scriptId) as Protocol.Runtime.ScriptId,
        callFrame.url as Platform.DevToolsPath.UrlString, callFrame.lineNumber, linkifyOptions);
  }

  static linkifyStackTraceFrame(frame: StackTrace.StackTrace.Frame, options?: LinkifyOptions): HTMLElement {
    const linkifyURLOptions = {
      ...options,
      lineNumber: frame.line,
      columnNumber: frame.column,
      showColumnNumber: Boolean(options?.showColumnNumber),
      className: options?.className,
      tabStop: options?.tabStop,
      inlineFrameIndex: options?.inlineFrameIndex ?? 0,
      userMetric: options?.userMetric,
      jslogContext: options?.jslogContext || 'script-location',
      omitOrigin: options?.omitOrigin,
    } satisfies LinkifyURLOptions;
    const {className = ''} = linkifyURLOptions;
    const fallbackAnchor = Linkifier.linkifyURL(frame.url as Platform.DevToolsPath.UrlString, linkifyURLOptions);
    if (!frame.uiSourceCode) {
      return fallbackAnchor;
    }

    const createLinkOptions: CreateLinkOptions = {
      tabStop: options?.tabStop,
      jslogContext: 'script-location',
    };
    const {link, linkInfo} = Linkifier.createLink(
        fallbackAnchor?.textContent ? fallbackAnchor.textContent : '', className, createLinkOptions);
    linkInfo.fallback = fallbackAnchor;
    linkInfo.userMetric = options?.userMetric;

    const linkDisplayOptions: LinkDisplayOptions = {
      showColumnNumber: linkifyURLOptions.showColumnNumber ?? false,
      maxLength: linkifyURLOptions.maxLength ?? UI.UIUtils.MaxLengthForDisplayedURLs,
      revealBreakpoint: options?.revealBreakpoint,
    };

    const uiLocation = frame.uiSourceCode.uiLocation(frame.line, frame.column) ?? null;
    Linkifier.updateAnchorFromUILocation(link, linkDisplayOptions, uiLocation);

    return link;
  }

  linkifyStackTraceTopFrame(target: SDK.Target.Target|null, stackTrace: Protocol.Runtime.StackTrace): HTMLElement {
    console.assert(stackTrace.callFrames.length > 0);

    const {url, lineNumber, columnNumber} = stackTrace.callFrames[0];
    const fallbackAnchor = Linkifier.linkifyURL(url as Platform.DevToolsPath.UrlString, {
      lineNumber,
      columnNumber,
      showColumnNumber: false,
      inlineFrameIndex: 0,
      maxLength: this.maxLength,
      preventClick: true,
      jslogContext: 'script-source-url',
    });

    // HAR imported network logs have no associated NetworkManager.
    if (!target) {
      return fallbackAnchor;
    }

    // The contract is that disposed targets don't have a LiveLocationPool
    // associated, whereas all active targets have one such pool. This ensures
    // that the fallbackAnchor is only ever used when the target was disposed.
    const pool = this.locationPoolByTarget.get(target);
    if (!pool || target.isDisposed()) {
      return fallbackAnchor;
    }

    // All targets that can report stack traces also have a debugger model.
    const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel) as SDK.DebuggerModel.DebuggerModel;

    const {link, linkInfo} = Linkifier.createLink('', '', {jslogContext: 'script-location'});
    linkInfo.enableDecorator = this.useLinkDecorator;
    linkInfo.fallback = fallbackAnchor;

    const linkDisplayOptions = {showColumnNumber: false, maxLength: this.maxLength};

    const updateDelegate = async(liveLocation: Bindings.LiveLocation.LiveLocation): Promise<void> => {
      await this.updateAnchor(link, linkDisplayOptions, liveLocation);
      this.dispatchEventToListeners(Events.LIVE_LOCATION_UPDATED, liveLocation);
    };
    void Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance()
        .createStackTraceTopFrameLiveLocation(
            debuggerModel.createRawLocationsByStackTrace(stackTrace), updateDelegate.bind(this), pool)
        .then(liveLocation => {
          linkInfo.liveLocation = liveLocation;
        });

    const anchors = (this.anchorsByTarget.get(target) as Element[]);
    anchors.push(link);
    return link;
  }

  linkifyCSSLocation(rawLocation: SDK.CSSModel.CSSLocation, classes?: string): Element {
    const createLinkOptions: CreateLinkOptions = {
      tabStop: true,
      jslogContext: 'css-location',
    };
    const {link, linkInfo} = Linkifier.createLink('', classes || '', createLinkOptions);
    linkInfo.enableDecorator = this.useLinkDecorator;

    const pool = this.locationPoolByTarget.get(rawLocation.cssModel().target());
    if (!pool) {
      return link;
    }

    const linkDisplayOptions = {showColumnNumber: false, maxLength: this.maxLength};

    const updateDelegate = async(liveLocation: Bindings.LiveLocation.LiveLocation): Promise<void> => {
      await this.updateAnchor(link, linkDisplayOptions, liveLocation);
      this.dispatchEventToListeners(Events.LIVE_LOCATION_UPDATED, liveLocation);
    };
    void Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance()
        .createLiveLocation(rawLocation, updateDelegate.bind(this), pool)
        .then(liveLocation => {
          linkInfo.liveLocation = liveLocation;
        });

    const anchors = (this.anchorsByTarget.get(rawLocation.cssModel().target()) as Element[]);
    anchors.push(link);
    return link;
  }

  reset(): void {
    // Create a copy of {keys} so {targetRemoved} can safely modify the map.
    for (const target of [...this.anchorsByTarget.keys()]) {
      this.targetRemoved(target);
      this.targetAdded(target);
    }
    this.listeners?.clear();
  }

  dispose(): void {
    Workspace.Workspace.WorkspaceImpl.instance().removeEventListener(
        Workspace.Workspace.Events.WorkingCopyChanged, this.#onWorkingCopyChangedOrCommitted, this);
    Workspace.Workspace.WorkspaceImpl.instance().removeEventListener(
        Workspace.Workspace.Events.WorkingCopyCommitted, this.#onWorkingCopyChangedOrCommitted, this);
    // Create a copy of {keys} so {targetRemoved} can safely modify the map.
    for (const target of [...this.anchorsByTarget.keys()]) {
      this.targetRemoved(target);
    }
    SDK.TargetManager.TargetManager.instance().unobserveTargets(this);
    instances.delete(this);
  }

  private async updateAnchor(
      anchor: HTMLElement, options: LinkDisplayOptions,
      liveLocation: Bindings.LiveLocation.LiveLocation): Promise<void> {
    Linkifier.unbindUILocation(anchor);
    if (options.revealBreakpoint) {
      Linkifier.unbindBreakpoint(anchor);
    }
    const uiLocation = await liveLocation.uiLocation();
    if (!uiLocation) {
      anchor.classList.add('invalid-link');
      anchor.removeAttribute('role');
      return;
    }

    this.#anchorUpdaters.set(anchor, function(this: Linkifier, anchor: HTMLElement) {
      void this.updateAnchor(anchor, options, liveLocation);
    });
    Linkifier.updateAnchorFromUILocation(anchor, options, uiLocation);
  }

  private static updateAnchorFromUILocation(
      anchor: HTMLElement, options: LinkDisplayOptions, uiLocation: Workspace.UISourceCode.UILocation|null): void {
    if (!uiLocation) {
      anchor.classList.add('invalid-link');
      anchor.removeAttribute('role');
      return;
    }

    Linkifier.bindUILocation(anchor, uiLocation);
    if (options.revealBreakpoint) {
      Linkifier.bindBreakpoint(anchor, uiLocation);
    }

    const text = uiLocation.linkText(true /* skipTrim */, options.showColumnNumber);
    Linkifier.setTrimmedText(anchor, text, options.maxLength);

    let titleText: string = uiLocation.uiSourceCode.url();
    if (uiLocation.uiSourceCode.mimeType() === 'application/wasm') {
      // For WebAssembly locations, we follow the conventions described in
      // github.com/WebAssembly/design/blob/master/Web.md#developer-facing-display-conventions
      if (typeof uiLocation.columnNumber === 'number') {
        titleText += `:0x${uiLocation.columnNumber.toString(16)}`;
      }
    } else {
      titleText += ':' + (uiLocation.lineNumber + 1);
      if (options.showColumnNumber && typeof uiLocation.columnNumber === 'number') {
        titleText += ':' + (uiLocation.columnNumber + 1);
      }
    }
    UI.Tooltip.Tooltip.install(anchor, titleText);
    const isIgnoreListed = Boolean(uiLocation?.isIgnoreListed());
    anchor.classList.toggle('ignore-list-link', isIgnoreListed);
    Linkifier.updateLinkDecorations(anchor);
  }

  private static updateLinkDecorations(anchor: Element): void {
    const info = Linkifier.linkInfo(anchor);
    if (!info?.enableDecorator) {
      return;
    }
    if (!decorator || !info.uiLocation) {
      return;
    }
    const icon = decorator.linkIcon(info.uiLocation.uiSourceCode);
    if (icon && anchor instanceof HTMLElement && anchor.firstElementChild instanceof HTMLElement) {
      anchor.firstElementChild?.style.setProperty('margin-left', '2px');
      render(icon, anchor, {renderBefore: anchor.firstElementChild});
    }
    info.icon = icon;
  }

  static renderLinkifiedUrl(url: Platform.DevToolsPath.UrlString, options?: LinkifyURLOptions): TemplateResult {
    options = options || {
      showColumnNumber: false,
      inlineFrameIndex: 0,
    };

    const text = options.text;
    const className = options.className || '';
    const lineNumber = options.lineNumber;
    const columnNumber = options.columnNumber;
    const showColumnNumber = options.showColumnNumber;
    const preventClick = options.preventClick;
    const maxLength = options.maxLength || UI.UIUtils.MaxLengthForDisplayedURLs;
    const bypassURLTrimming = options.bypassURLTrimming;
    const omitOrigin = options.omitOrigin;

    if (!url || Common.ParsedURL.schemeIs(url, 'javascript:')) {
      // clang-format off
      return html`<span class=${className}>${text || url || i18nString(UIStrings.unknown)}</span>`;
      // clang-format on
    }

    // FIXME: Bindings.ResourceUtils.displayNameForURL should be called in presenters.
    let linkText = text || Bindings.ResourceUtils.displayNameForURL(url);

    if (omitOrigin) {
      const parsedUrl = URL.parse(url);
      if (parsedUrl) {
        linkText = url.replace(parsedUrl.origin, '');
      }
    }

    if (typeof lineNumber === 'number' && !text) {
      linkText += ':' + (lineNumber + 1);
      if (showColumnNumber && typeof columnNumber === 'number') {
        linkText += ':' + (columnNumber + 1);
      }
    }
    const title = linkText !== url ? url : '';
    const linkOptions = {
      maxLength,
      title,
      href: url,
      preventClick,
      tabStop: options.tabStop,
      bypassURLTrimming,
      jslogContext: options.jslogContext || 'url',
      lineNumber,
      columnNumber,
      userMetric: options?.userMetric,
      onRef: options.onRef,
    };
    return Linkifier.renderLink(linkText, className, linkOptions);
  }

  /**
   * @deprecated use renderLinkifiedUrl.
   */
  static linkifyURL(url: Platform.DevToolsPath.UrlString, options?: LinkifyURLOptions): HTMLElement {
    const container = document.createDocumentFragment();
    render(Linkifier.renderLinkifiedUrl(url, options), container);
    return container.firstElementChild as HTMLElement;
  }

  static linkifyRevealable(
      revealable: Object, text: string|HTMLElement, fallbackHref?: Platform.DevToolsPath.UrlString, title?: string,
      className?: string, jslogContext?: string): HTMLElement {
    const createLinkOptions: CreateLinkOptions = {
      maxLength: UI.UIUtils.MaxLengthForDisplayedURLs,
      href: (fallbackHref),
      title,
      jslogContext,
    };
    const {link, linkInfo} = Linkifier.createLink(text, className || '', createLinkOptions);
    linkInfo.revealable = revealable;
    return link;
  }

  private static renderLink(text: string|HTMLElement, className: string, options: CreateLinkOptions = {}):
      TemplateResult {
    const {maxLength, title, href, preventClick, tabStop, bypassURLTrimming, jslogContext} = options;
    const classes: Record<string, boolean> = {
      'devtools-link': true,
      'text-button': !preventClick,
      'link-style': !preventClick,
      'devtools-link-prevent-click': !!preventClick,
    };
    // More than one class name may be passed.
    for (const cls of className.split(' ')) {
      if (cls) {
        classes[cls] = true;
      }
    }
    const handler = (event: MouseEvent|KeyboardEvent): void => {
      if (event instanceof KeyboardEvent && event.key !== Platform.KeyboardUtilities.ENTER_KEY && event.key !== ' ') {
        return;
      }
      if (Linkifier.handleClick(event)) {
        event.consume(true);
      }
    };
    const createRef = (): ReturnType<typeof ref> => {
      return ref(link => {
        if (!link) {
          return;
        }
        options.onRef?.(link as HTMLElement);
        if (text instanceof HTMLElement) {
          link.appendChild(text);
        } else if (bypassURLTrimming) {
          link.classList.add('devtools-link-styled-trim');
          Linkifier.appendTextWithoutHashes(link, text);
        } else {
          Linkifier.setTrimmedText(link, text, maxLength);
        }
        const linkInfo = {
          icon: null,
          enableDecorator: false,
          uiLocation: null,
          liveLocation: null,
          url: options.href || null,
          lineNumber: options.lineNumber ?? null,
          columnNumber: options.columnNumber ?? null,
          inlineFrameIndex: 0,
          revealable: null,
          fallback: null,
          userMetric: options.userMetric,
        };
        infoByAnchor.set(link, linkInfo);
      });
    };
    const jslog = VisualLogging.link(jslogContext).track({click: true});
    // clang-format off
    return preventClick ? html`<span
      class=${classMap(classes)}
      .href=${href}
      title=${ifDefined(title ? title : undefined)}
      jslog=${jslog}
      .tabIndex=${tabStop ? 0 : -1}
      role="link"
      ${createRef()}></span>` : html`<button
        @click=${handler}
        @keydown=${handler}
        class=${classMap(classes)}
        .href=${href}
        title=${ifDefined(title ? title : undefined)}
        jslog=${jslog}
        .tabIndex=${tabStop ? 0 : -1}
        role="link"
        ${createRef()}></button>`;
    // clang-format on
  }

  /**
   * @deprecated use renderLink.
   */
  private static createLink(text: string|HTMLElement, className: string, options: CreateLinkOptions = {}):
      {link: HTMLElement, linkInfo: LinkInfo} {
    const container = document.createDocumentFragment();
    render(Linkifier.renderLink(text, className, options), container);
    const link = container.firstElementChild as HTMLElement;
    const linkInfo = infoByAnchor.get(link) as LinkInfo;
    return {link, linkInfo};
  }

  private static setTrimmedText(link: Element, text: string, maxLength?: number): void {
    link.removeChildren();
    if (maxLength && text.length > maxLength) {
      const middleSplit = splitMiddle(text, maxLength);
      Linkifier.appendTextWithoutHashes(link, middleSplit[0]);
      Linkifier.appendHiddenText(link, middleSplit[1]);
      Linkifier.appendTextWithoutHashes(link, middleSplit[2]);
    } else {
      Linkifier.appendTextWithoutHashes(link, text);
    }

    function splitMiddle(string: string, maxLength: number): string[] {
      let leftIndex = Math.floor(maxLength / 2);
      let rightIndex = string.length - Math.ceil(maxLength / 2) + 1;

      const codePointAtRightIndex = string.codePointAt(rightIndex - 1);
      // Do not truncate between characters that use multiple code points (emojis).
      if (typeof codePointAtRightIndex !== 'undefined' && codePointAtRightIndex >= 0x10000) {
        rightIndex++;
        leftIndex++;
      }
      const codePointAtLeftIndex = string.codePointAt(leftIndex - 1);
      if (typeof codePointAtLeftIndex !== 'undefined' && leftIndex > 0 && codePointAtLeftIndex >= 0x10000) {
        leftIndex--;
      }
      return [string.substring(0, leftIndex), string.substring(leftIndex, rightIndex), string.substring(rightIndex)];
    }
  }

  private static appendTextWithoutHashes(link: Element, string: string): void {
    const hashSplit = TextUtils.TextUtils.Utils.splitStringByRegexes(string, [/[a-f0-9]{20,}/g]);
    for (const match of hashSplit) {
      if (match.regexIndex === -1) {
        UI.UIUtils.createTextChild(link, match.value);
      } else {
        UI.UIUtils.createTextChild(link, match.value.substring(0, 7));
        Linkifier.appendHiddenText(link, match.value.substring(7));
      }
    }
  }

  private static appendHiddenText(link: Element, string: string): void {
    const ellipsisNode = UI.UIUtils.createTextChild(link.createChild('span', 'devtools-link-ellipsis'), '…');
    textByAnchor.set(ellipsisNode, string);
  }

  static untruncatedNodeText(node: Node): string {
    return textByAnchor.get(node) || node.textContent || '';
  }

  static linkInfo(link: Element|null): LinkInfo|null {
    return link ? infoByAnchor.get(link) || null : null as LinkInfo | null;
  }

  private static handleClick(event: Event): boolean {
    const link = (event.currentTarget as Element);
    if (UI.UIUtils.isBeingEdited((event.target as Node)) || link.hasSelection()) {
      return false;
    }
    const linkInfo = Linkifier.linkInfo(link);
    if (!linkInfo) {
      return false;
    }
    return Linkifier.invokeFirstAction(linkInfo);
  }

  static handleClickFromNewComponentLand(linkInfo: LinkInfo): void {
    Linkifier.invokeFirstAction(linkInfo);
  }

  static invokeFirstAction(linkInfo: LinkInfo): boolean {
    const actions = Linkifier.linkActions(linkInfo);
    if (actions.length) {
      void actions[0].handler.call(null);
      if (linkInfo.userMetric) {
        Host.userMetrics.actionTaken(linkInfo.userMetric);
      }
      return true;
    }
    return false;
  }

  static linkHandlerSetting(): Common.Settings.Setting<string> {
    if (!linkHandlerSettingInstance) {
      linkHandlerSettingInstance =
          Common.Settings.Settings.instance().createSetting('open-link-handler', i18nString(UIStrings.auto));
    }
    return linkHandlerSettingInstance;
  }

  static registerLinkHandler(registration: LinkHandlerRegistration): void {
    for (const origin of linkHandlers.keys()) {
      const existingHandler = linkHandlers.get(origin);
      if (existingHandler?.scheme === registration.scheme) {
        const schemeString = registration.scheme ? `scheme '${registration.scheme}'` : 'all schemes';
        Common.Console.Console.instance().warn(
            `DevTools extension '${registration.title}' registered with setOpenResourceHandler for ${
                schemeString}, which is already registered by '${
                existingHandler?.title}'. This can lead to unexpected results.`);
      }
    }

    linkHandlers.set(registration.origin, registration);
    LinkHandlerSettingUI.instance().update();
  }

  static unregisterLinkHandler(registration: LinkHandlerRegistration): void {
    const {origin} = registration;
    linkHandlers.delete(origin);
    LinkHandlerSettingUI.instance().update();
  }

  // The primary filter implementation for the openResourceHandlers. Returns false
  // if the handler is NOT supposed to handle the `url`. Usually, this happens if
  // a handler has registered for a particular `scheme` and the scheme for that url
  // does not match. If no openResourceScheme is provided, it means the handler is
  // interested in all urls (except those handled by scheme-specific handlers, see
  // otherSchemeRegistrations).
  static shouldHandleOpenResource(
      openResourceScheme: string|null, url: Platform.DevToolsPath.UrlString,
      otherSchemeRegistrations: Set<string>): boolean {
    // If this is a scheme-specific handler, make sure the registered scheme is
    // present in the url.
    if (openResourceScheme) {
      return url.startsWith(openResourceScheme);
    }

    // Global handlers (that register for no scheme) can handle all urls, with the
    // exception of urls that scheme-specific handlers have registered for.
    const scheme = URL.parse(url)?.protocol || '';
    return !otherSchemeRegistrations.has(scheme);
  }

  static uiLocation(link: Element): Workspace.UISourceCode.UILocation|null {
    const info = Linkifier.linkInfo(link);
    return info ? info.uiLocation : null;
  }

  static linkActions(info: LinkInfo): Array<{
    section: string,
    title: string,
    jslogContext: string,
    handler: () => Promise<void>| void,
  }> {
    const result: Array<{
      section: string,
      title: string,
      jslogContext: string,
      handler: () => Promise<void>| void,
    }> = [];

    if (!info) {
      return result;
    }

    let url = Platform.DevToolsPath.EmptyUrlString;
    let uiLocation: Workspace.UISourceCode.UILocation|(Workspace.UISourceCode.UILocation | null)|null = null;
    if (info.uiLocation) {
      uiLocation = info.uiLocation;
      url = uiLocation.uiSourceCode.contentURL();
    } else if (info.url) {
      url = info.url;
      const uiSourceCode = Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(url) ||
          Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(
              Common.ParsedURL.ParsedURL.urlWithoutHash(url) as Platform.DevToolsPath.UrlString);
      uiLocation = uiSourceCode ? uiSourceCode.uiLocation(info.lineNumber || 0, info.columnNumber || 0) : null;
    }
    const resource = url ? Bindings.ResourceUtils.resourceForURL(url) : null;
    const contentProvider = uiLocation ? uiLocation.uiSourceCode : resource;

    const revealable = info.revealable || uiLocation || resource;
    if (revealable) {
      const destination = Common.Revealer.revealDestination(revealable);
      result.push({
        section: 'reveal',
        title: destination ? i18nString(UIStrings.revealInS, {PH1: destination}) : i18nString(UIStrings.reveal),
        jslogContext: 'reveal',
        handler: () => Common.Revealer.reveal(revealable),
      });
    }

    const contentProviderOrUrl = contentProvider || url;
    const lineNumber = uiLocation ? uiLocation.lineNumber : info.lineNumber || 0;
    const columnNumber = uiLocation ? uiLocation.columnNumber : info.columnNumber || 0;

    // Build the set of schemes that the currently registered extensions handle
    // (not counting ones that are scheme-agnostic).
    const specificSchemeHandlers = new Set<string>();
    for (const registration of linkHandlers.values()) {
      if (registration.scheme) {
        specificSchemeHandlers.add(registration.scheme);
      }
    }

    for (const registration of linkHandlers.values().filter(r => r.handler)) {
      const {title, handler, shouldHandleOpenResource} = registration;
      if (url && !shouldHandleOpenResource(url, specificSchemeHandlers)) {
        continue;
      }
      const action = {
        section: 'reveal',
        title: i18nString(UIStrings.openUsingS, {PH1: title}),
        jslogContext: 'open-using',
        handler: handler.bind(null, contentProviderOrUrl, lineNumber, columnNumber),
      };
      if (title === Linkifier.linkHandlerSetting().get()) {
        result.unshift(action);
      } else {
        result.push(action);
      }
    }
    if (resource || info.url) {
      result.push({
        section: 'reveal',
        title: UI.UIUtils.openLinkExternallyLabel(),
        jslogContext: 'open-in-new-tab',
        handler: () => UIHelpers.openInNewTab(url),
      });
      result.push({
        section: 'clipboard',
        title: UI.UIUtils.copyLinkAddressLabel(),
        jslogContext: 'copy-link-address',
        handler: () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(url),
      });
    }

    if (uiLocation?.uiSourceCode) {
      const contentProvider = uiLocation.uiSourceCode;
      result.push({
        section: 'clipboard',
        title: UI.UIUtils.copyFileNameLabel(),
        jslogContext: 'copy-file-name',
        handler: () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(contentProvider.displayName()),
      });
    }

    return result;
  }
}

export interface LinkDecorator extends Common.EventTarget.EventTarget<LinkDecorator.EventTypes> {
  linkIcon(uiSourceCode: Workspace.UISourceCode.UISourceCode): LitTemplate|null;
}

export namespace LinkDecorator {
  export const enum Events {
    LINK_ICON_CHANGED = 'LinkIconChanged',
  }

  export interface EventTypes {
    [Events.LINK_ICON_CHANGED]: Workspace.UISourceCode.UISourceCode;
  }
}

export class LinkContextMenuProvider implements UI.ContextMenu.Provider<Node> {
  appendApplicableItems(_event: Event, contextMenu: UI.ContextMenu.ContextMenu, target: Node): void {
    let targetNode: Node|null = target;
    while (targetNode && !infoByAnchor.get(targetNode)) {
      targetNode = targetNode.parentNodeOrShadowHost();
    }
    const link = (targetNode as Element | null);
    const linkInfo = Linkifier.linkInfo(link);
    if (!linkInfo) {
      return;
    }

    const actions = Linkifier.linkActions(linkInfo);
    for (const action of actions) {
      contextMenu.section(action.section).appendItem(action.title, action.handler, {jslogContext: action.jslogContext});
    }
  }
}

let linkHandlerSettingUIInstance: LinkHandlerSettingUI;

export class LinkHandlerSettingUI {
  private element: HTMLSelectElement;

  private constructor() {
    this.element = document.createElement('select');
    this.element.addEventListener('change', this.onChange.bind(this), false);
    this.update();
  }

  static instance(opts: {
    forceNew: boolean|null,
  } = {forceNew: null}): LinkHandlerSettingUI {
    const {forceNew} = opts;
    if (!linkHandlerSettingUIInstance || forceNew) {
      linkHandlerSettingUIInstance = new LinkHandlerSettingUI();
    }

    return linkHandlerSettingUIInstance;
  }

  update(): void {
    this.element.removeChildren();
    const names = [...linkHandlers.keys()];
    names.unshift(i18nString(UIStrings.auto));
    for (const name of names) {
      const option = document.createElement('option');
      option.textContent = name;
      option.selected = name === Linkifier.linkHandlerSetting().get();
      this.element.appendChild(option);
    }
    this.element.disabled = names.length <= 1;
  }

  private onChange(event: Event): void {
    if (!event.target) {
      return;
    }
    const value = (event.target as HTMLSelectElement).value;
    Linkifier.linkHandlerSetting().set(value);
  }

  settingElement(): Element {
    const p = document.createElement('p');
    p.classList.add('settings-select');
    const label = p.createChild('label');
    label.textContent = i18nString(UIStrings.linkHandling);
    UI.ARIAUtils.bindLabelToControl(label, this.element);
    p.appendChild(this.element);
    return p;
  }
}

let listeningToNewEvents = false;
function listenForNewComponentLinkifierEvents(): void {
  if (listeningToNewEvents) {
    return;
  }

  listeningToNewEvents = true;

  window.addEventListener('linkifieractivated', function(event: Event) {
    const eventWithData = (event as unknown as {
      data: LinkInfo,
    });
    Linkifier.handleClickFromNewComponentLand(eventWithData.data);
  });
}

listenForNewComponentLinkifierEvents();

export class ContentProviderContextMenuProvider implements
    UI.ContextMenu
        .Provider<Workspace.UISourceCode.UISourceCode|SDK.Resource.Resource|SDK.NetworkRequest.NetworkRequest> {
  appendApplicableItems(
      _event: Event, contextMenu: UI.ContextMenu.ContextMenu,
      contentProvider: Workspace.UISourceCode.UISourceCode|SDK.Resource.Resource|
      SDK.NetworkRequest.NetworkRequest): void {
    const contentUrl = contentProvider.contentURL();
    if (!contentUrl) {
      return;
    }

    if (!Common.ParsedURL.schemeIs(contentUrl, 'file:')) {
      contextMenu.revealSection().appendItem(
          UI.UIUtils.openLinkExternallyLabel(),
          () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab(
              contentUrl.endsWith(':formatted') ?
                  Common.ParsedURL.ParsedURL.slice(contentUrl, 0, contentUrl.lastIndexOf(':')) :
                  contentUrl),
          {jslogContext: 'open-in-new-tab'});
    }
    for (const origin of linkHandlers.keys()) {
      const registration = linkHandlers.get(origin);
      if (!registration) {
        continue;
      }
      const {title} = registration;
      contextMenu.revealSection().appendItem(
          i18nString(UIStrings.openUsingS, {PH1: title}), registration.handler.bind(null, contentProvider, 0),
          {jslogContext: 'open-using'});
    }
    if (contentProvider instanceof SDK.NetworkRequest.NetworkRequest) {
      return;
    }

    contextMenu.clipboardSection().appendItem(
        UI.UIUtils.copyLinkAddressLabel(),
        () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(contentUrl),
        {jslogContext: 'copy-link-address'});

    // TODO(bmeurer): `displayName` should be an accessor/data property consistently.
    if (contentProvider instanceof Workspace.UISourceCode.UISourceCode) {
      contextMenu.clipboardSection().appendItem(
          UI.UIUtils.copyFileNameLabel(),
          () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(contentProvider.displayName()),
          {jslogContext: 'copy-file-name'});
    } else {
      contextMenu.clipboardSection().appendItem(
          UI.UIUtils.copyFileNameLabel(),
          () => Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(contentProvider.displayName),
          {jslogContext: 'copy-file-name'});
    }
  }
}

interface LinkInfo {
  icon: LitTemplate|null;
  enableDecorator: boolean;
  uiLocation: Workspace.UISourceCode.UILocation|null;
  liveLocation: Bindings.LiveLocation.LiveLocation|null;
  url: Platform.DevToolsPath.UrlString|null;
  lineNumber: number|null;
  columnNumber: number|null;
  inlineFrameIndex: number;
  revealable: Object|null;
  fallback: Element|null;
  userMetric?: Host.UserMetrics.Action;
  jslogContext?: string;
}

export interface LinkifyURLOptions {
  text?: string;
  className?: string;
  lineNumber?: number;
  columnNumber?: number;
  showColumnNumber?: boolean;
  inlineFrameIndex?: number;
  preventClick?: boolean;
  maxLength?: number;
  tabStop?: boolean;
  bypassURLTrimming?: boolean;
  userMetric?: Host.UserMetrics.Action;
  jslogContext?: string;
  omitOrigin?: boolean;
  onRef?: (el: HTMLElement) => void;
}

export interface LinkifyOptions {
  className?: string;
  columnNumber?: number;
  showColumnNumber?: boolean;
  inlineFrameIndex?: number;
  tabStop?: boolean;
  userMetric?: Host.UserMetrics.Action;
  jslogContext?: string;
  omitOrigin?: boolean;

  /**
   * {@link LinkDisplayOptions.revealBreakpoint}
   */
  revealBreakpoint?: boolean;
  maxLength?: number;
}

interface CreateLinkOptions {
  maxLength?: number;
  title?: string;
  href?: Platform.DevToolsPath.UrlString;
  preventClick?: boolean;
  tabStop?: boolean;
  bypassURLTrimming?: boolean;
  jslogContext?: string;
  lineNumber?: number;
  columnNumber?: number;
  userMetric?: Host.UserMetrics.Action;
  onRef?: (el: HTMLElement) => void;
}

interface LinkDisplayOptions {
  showColumnNumber: boolean;
  maxLength: number;

  /**
   * If true, we'll check if there is a breakpoint at the UILocation we get
   * from the LiveLocation. If we find a breakpoint, we'll reveal the corresponding
   * {@link Breakpoints.BreakpointManager.BreakpointLocation}. Which opens the
   * breakpoint edit dialog.
   */
  revealBreakpoint?: boolean;
}

/**
 * The filter function for the openResourceHandlers. Returns true if the `url`
 * should be considered for a particular handler. `specificSchemeHandlers`
 * is the set of all schemes handled by all registered DevTools extensions
 * (that specify a particular scheme).
 **/
export type LinkHandlerPredicate = (url: Platform.DevToolsPath.UrlString, specificSchemeHandlers: Set<string>) =>
    boolean;

export type LinkHandler =
    (arg0: TextUtils.ContentProvider.ContentProvider|Platform.DevToolsPath.UrlString, lineNumber: number,
     columnNumber?: number) => void;

export interface LinkHandlerRegistration {
  // The title (read: manifest name) of DevTools extension registering as an openResourceHandler.
  // This value is provided by the developer of the extension.
  title: string;
  // The origin of the DevTools extension handling the url.
  origin: Platform.DevToolsPath.UrlString;
  // The scheme that the handler wants to register for. If set, only links that match this scheme
  // will be considered, otherwise all links will be considered.
  scheme?: string;
  // The openResourceHandler handling the requests to open a resource.
  handler: LinkHandler;
  // A filter function used to determine whether the `handler` wants to handle the link clicks.
  shouldHandleOpenResource: LinkHandlerPredicate;
}

export const enum Events {
  LIVE_LOCATION_UPDATED = 'liveLocationUpdated',
}

export interface EventTypes {
  [Events.LIVE_LOCATION_UPDATED]: Bindings.LiveLocation.LiveLocation;
}

interface ScriptLocationViewInput {
  target?: SDK.Target.Target;
  scriptId?: Protocol.Runtime.ScriptId;
  sourceURL: Platform.DevToolsPath.UrlString;
  lineNumber?: number;
  options?: LinkifyOptions;
  linkifier: Linkifier;
}

type ScriptLocationView = (input: ScriptLocationViewInput, output: undefined, target: HTMLElement) => void;

const DEFAULT_SCRIPT_LOCATION_VIEW: ScriptLocationView = (input, _output, target) => {
  render(
      html`${
          input.linkifier.linkifyScriptLocation(
              input.target ?? null, input.scriptId ?? null, input.sourceURL, input.lineNumber, input.options)}`,
      target);
};

export class ScriptLocationLink extends UI.Widget.Widget {
  target?: SDK.Target.Target;
  scriptId?: Protocol.Runtime.ScriptId;
  sourceURL = '' as Platform.DevToolsPath.UrlString;
  lineNumber?: number;
  options?: LinkifyOptions;
  linkifier = new Linkifier();
  #view: ScriptLocationView;

  constructor(element: HTMLElement, view = DEFAULT_SCRIPT_LOCATION_VIEW) {
    super(element);
    this.#view = view;
  }

  override performUpdate(): void {
    this.#view(this, undefined, this.contentElement);
  }

  override onDetach(): void {
    this.linkifier.dispose();
  }
}
