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

import type {Chrome} from '../../../extension-api/ExtensionAPI.js';
import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import type * as Platform from '../../core/platform/platform.js';
import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import * as StackTrace from '../stack_trace/stack_trace.js';
import type * as StackTraceImpl from '../stack_trace/stack_trace_impl.js';
import * as TextUtils from '../text_utils/text_utils.js';
import * as Workspace from '../workspace/workspace.js';

import {ContentProviderBasedProject} from './ContentProviderBasedProject.js';
import type {DebuggerWorkspaceBinding} from './DebuggerWorkspaceBinding.js';
import {NetworkProject} from './NetworkProject.js';

const UIStrings = {
  /**
   * @description Error message that is displayed in the Console when language #plugins report errors
   * @example {File not found} PH1
   */
  errorInDebuggerLanguagePlugin: 'Error in debugger language plugin: {PH1}',
  /**
   * @description Status message that is shown in the Console when debugging information is being
   *loaded. The 2nd and 3rd placeholders are URLs.
   * @example {C/C++ DevTools Support (DWARF)} PH1
   * @example {http://web.dev/file.wasm} PH2
   * @example {http://web.dev/file.wasm.debug.wasm} PH3
   */
  loadingDebugSymbolsForVia: '[{PH1}] Loading debug symbols for {PH2} (via {PH3})…',
  /**
   * @description Status message that is shown in the Console when debugging information is being loaded
   * @example {C/C++ DevTools Support (DWARF)} PH1
   * @example {http://web.dev/file.wasm} PH2
   */
  loadingDebugSymbolsFor: '[{PH1}] Loading debug symbols for {PH2}…',
  /**
   * @description Warning message that is displayed in the Console when debugging information was loaded, but no source files were found
   * @example {C/C++ DevTools Support (DWARF)} PH1
   * @example {http://web.dev/file.wasm} PH2
   */
  loadedDebugSymbolsForButDidnt: '[{PH1}] Loaded debug symbols for {PH2}, but didn\'t find any source files',
  /**
   * @description Status message that is shown in the Console when debugging information is successfully loaded
   * @example {C/C++ DevTools Support (DWARF)} PH1
   * @example {http://web.dev/file.wasm} PH2
   * @example {42} PH3
   */
  loadedDebugSymbolsForFound: '[{PH1}] Loaded debug symbols for {PH2}, found {PH3} source file(s)',
  /**
   * @description Error message that is displayed in the Console when debugging information cannot be loaded
   * @example {C/C++ DevTools Support (DWARF)} PH1
   * @example {http://web.dev/file.wasm} PH2
   * @example {File not found} PH3
   */
  failedToLoadDebugSymbolsFor: '[{PH1}] Failed to load debug symbols for {PH2} ({PH3})',
} as const;
const str_ = i18n.i18n.registerUIStrings('models/bindings/DebuggerLanguagePlugins.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

/**
 * Generates the raw module ID for a script, which is used
 * to uniquely identify the debugging data for a script on
 * the responsible language #plugin.
 *
 * @param script the unique raw module ID for the script.
 */
function rawModuleIdForScript(script: SDK.Script.Script): string {
  return `${script.sourceURL}@${script.hash}`;
}

function getRawLocation(callFrame: SDK.DebuggerModel.CallFrame): Chrome.DevTools.RawLocation {
  const {script} = callFrame;
  return {
    rawModuleId: rawModuleIdForScript(script),
    codeOffset: callFrame.location().columnNumber - (script.codeOffset() || 0),
    inlineFrameIndex: callFrame.inlineFrameIndex,
  };
}

class FormattingError extends Error {
  exception: SDK.RemoteObject.RemoteObject;
  exceptionDetails: Protocol.Runtime.ExceptionDetails;
  constructor(exception: SDK.RemoteObject.RemoteObject, exceptionDetails: Protocol.Runtime.ExceptionDetails) {
    const {description} = exceptionDetails.exception || {};
    super(description || exceptionDetails.text);
    this.exception = exception;
    this.exceptionDetails = exceptionDetails;
  }

  static makeLocal(callFrame: SDK.DebuggerModel.CallFrame, message: string): FormattingError {
    const exception: Protocol.Runtime.RemoteObject = {
      type: Protocol.Runtime.RemoteObjectType.Object,
      subtype: Protocol.Runtime.RemoteObjectSubtype.Error,
      description: message,
    };
    const exceptionDetails: Protocol.Runtime
        .ExceptionDetails = {text: 'Uncaught', exceptionId: -1, columnNumber: 0, lineNumber: 0, exception};
    const errorObject = callFrame.debuggerModel.runtimeModel().createRemoteObject(exception);
    return new FormattingError(errorObject, exceptionDetails);
  }
}

class NamespaceObject extends SDK.RemoteObject.LocalJSONObject {
  override get description(): string {
    return this.type;
  }

  override get type(): string {
    return 'namespace';
  }
}

async function getRemoteObject(callFrame: SDK.DebuggerModel.CallFrame, object: Chrome.DevTools.ForeignObject):
    Promise<Protocol.Runtime.RemoteObject> {
  if (!/^(local|global|operand)$/.test(object.valueClass)) {
    return {type: Protocol.Runtime.RemoteObjectType.Undefined};
  }
  const index = Number(object.index);
  const expression = `${object.valueClass}s[${index}]`;
  const response = await callFrame.debuggerModel.agent.invoke_evaluateOnCallFrame({
    callFrameId: callFrame.id,
    expression,
    silent: true,
    generatePreview: true,
    throwOnSideEffect: true,
  });
  if (response.getError() || response.exceptionDetails) {
    return {type: Protocol.Runtime.RemoteObjectType.Undefined};
  }
  return response.result;
}

async function wrapRemoteObject(
    callFrame: SDK.DebuggerModel.CallFrame, object: Chrome.DevTools.RemoteObject|Chrome.DevTools.ForeignObject,
    plugin: DebuggerLanguagePlugin): Promise<SDK.RemoteObject.RemoteObject> {
  if (object.type === 'reftype') {
    const obj = await getRemoteObject(callFrame, object);
    return callFrame.debuggerModel.runtimeModel().createRemoteObject(obj);
  }
  return new ExtensionRemoteObject(callFrame, object, plugin);
}

class SourceScopeRemoteObject extends SDK.RemoteObject.RemoteObjectImpl {
  variables: Chrome.DevTools.Variable[];
  #callFrame: SDK.DebuggerModel.CallFrame;
  #plugin: DebuggerLanguagePlugin;
  stopId: StopId;

  constructor(callFrame: SDK.DebuggerModel.CallFrame, stopId: StopId, plugin: DebuggerLanguagePlugin) {
    super(callFrame.debuggerModel.runtimeModel(), undefined, 'object', undefined, null);
    this.variables = [];
    this.#callFrame = callFrame;
    this.#plugin = plugin;
    this.stopId = stopId;
  }

  override async doGetProperties(_ownProperties: boolean, accessorPropertiesOnly: boolean, _generatePreview: boolean):
      Promise<SDK.RemoteObject.GetPropertiesResult> {
    if (accessorPropertiesOnly) {
      return {properties: [], internalProperties: []} as SDK.RemoteObject.GetPropertiesResult;
    }

    const properties = [];
    const namespaces: Record<string, SDK.RemoteObject.RemoteObject> = {};

    function makeProperty(name: string, obj: SDK.RemoteObject.RemoteObject): SDK.RemoteObject.RemoteObjectProperty {
      return new SDK.RemoteObject.RemoteObjectProperty(
          name, obj,
          /* enumerable=*/ false, /* writable=*/ false, /* isOwn=*/ true, /* wasThrown=*/ false);
    }

    for (const variable of this.variables) {
      let sourceVar: SDK.RemoteObject.RemoteObject|undefined;
      try {
        const evalResult = await this.#plugin.evaluate(variable.name, getRawLocation(this.#callFrame), this.stopId);
        sourceVar = evalResult ? await wrapRemoteObject(this.#callFrame, evalResult, this.#plugin) :
                                 new SDK.RemoteObject.LocalJSONObject(undefined);
      } catch (e) {
        console.warn(e);
        sourceVar = new SDK.RemoteObject.LocalJSONObject(undefined);
      }
      if (variable.nestedName && variable.nestedName.length > 1) {
        let parent: Record<string, SDK.RemoteObject.RemoteObject> = namespaces;
        for (let index = 0; index < variable.nestedName.length - 1; index++) {
          const nestedName = variable.nestedName[index];
          let child: NamespaceObject|SDK.RemoteObject.RemoteObject = parent[nestedName];
          if (!child) {
            child = new NamespaceObject({});
            parent[nestedName] = child;
          }
          parent = child.value;
        }
        const name = variable.nestedName[variable.nestedName.length - 1];
        parent[name] = sourceVar;
      } else {
        properties.push(makeProperty(variable.name, sourceVar));
      }
    }

    for (const namespace in namespaces) {
      properties.push(makeProperty(namespace, (namespaces[namespace])));
    }

    return {properties, internalProperties: []};
  }
}

export class SourceScope implements SDK.DebuggerModel.ScopeChainEntry {
  readonly #callFrame: SDK.DebuggerModel.CallFrame;
  readonly #type: string;
  readonly #typeName: string;
  readonly #icon: string|undefined;
  readonly #object: SourceScopeRemoteObject;
  constructor(
      callFrame: SDK.DebuggerModel.CallFrame, stopId: StopId, type: string, typeName: string, icon: string|undefined,
      plugin: DebuggerLanguagePlugin) {
    if (icon && new URL(icon).protocol !== 'data:') {
      throw new Error('The icon must be a data:-URL');
    }
    this.#callFrame = callFrame;
    this.#type = type;
    this.#typeName = typeName;
    this.#icon = icon;
    this.#object = new SourceScopeRemoteObject(callFrame, stopId, plugin);
  }

  async getVariableValue(name: string): Promise<SDK.RemoteObject.RemoteObject|null> {
    for (let v = 0; v < this.#object.variables.length; ++v) {
      if (this.#object.variables[v].name !== name) {
        continue;
      }
      const properties = await this.#object.getAllProperties(false, false);
      if (!properties.properties) {
        continue;
      }
      const {value} = properties.properties[v];
      if (value) {
        return value;
      }
    }
    return null;
  }

  callFrame(): SDK.DebuggerModel.CallFrame {
    return this.#callFrame;
  }

  type(): string {
    return this.#type;
  }

  typeName(): string {
    return this.#typeName;
  }

  name(): string|undefined {
    return undefined;
  }

  range(): null {
    return null;
  }

  object(): SourceScopeRemoteObject {
    return this.#object;
  }

  description(): string {
    return '';
  }

  icon(): string|undefined {
    return this.#icon;
  }

  extraProperties(): SDK.RemoteObject.RemoteObjectProperty[] {
    return [];
  }
}

export class ExtensionRemoteObject extends SDK.RemoteObject.RemoteObject {
  private readonly extensionObject: Chrome.DevTools.RemoteObject;
  private readonly plugin: DebuggerLanguagePlugin;
  readonly callFrame: SDK.DebuggerModel.CallFrame;
  constructor(
      callFrame: SDK.DebuggerModel.CallFrame, extensionObject: Chrome.DevTools.RemoteObject,
      plugin: DebuggerLanguagePlugin) {
    super();
    this.extensionObject = extensionObject;
    this.plugin = plugin;
    this.callFrame = callFrame;
  }

  get linearMemoryAddress(): number|undefined {
    return this.extensionObject.linearMemoryAddress;
  }

  get linearMemorySize(): number|undefined {
    return this.extensionObject.linearMemorySize;
  }

  override get objectId(): Protocol.Runtime.RemoteObjectId|undefined {
    return this.extensionObject.objectId as Protocol.Runtime.RemoteObjectId;
  }

  override get type(): string {
    if (this.extensionObject.type === 'array' || this.extensionObject.type === 'null') {
      return 'object';
    }
    return this.extensionObject.type;
  }

  override get subtype(): string|undefined {
    if (this.extensionObject.type === 'array' || this.extensionObject.type === 'null') {
      return this.extensionObject.type;
    }
    return undefined;
  }

  override get value(): unknown {
    return this.extensionObject.value;
  }

  override unserializableValue(): string|undefined {
    return undefined;
  }

  override get description(): string|undefined {
    return this.extensionObject.description;
  }

  override set description(_description: string|undefined) {
  }

  override get hasChildren(): boolean {
    return this.extensionObject.hasChildren;
  }

  override get preview(): Protocol.Runtime.ObjectPreview|undefined {
    return undefined;
  }

  override get className(): string|null {
    return this.extensionObject.className ?? null;
  }

  override arrayLength(): number {
    return 0;
  }

  override arrayBufferByteLength(): number {
    return 0;
  }

  override getOwnProperties(_generatePreview: boolean, _nonIndexedPropertiesOnly?: boolean):
      Promise<SDK.RemoteObject.GetPropertiesResult> {
    return this.getAllProperties(false, _generatePreview, _nonIndexedPropertiesOnly);
  }

  override async getAllProperties(
      _accessorPropertiesOnly: boolean, _generatePreview: boolean,
      _nonIndexedPropertiesOnly?: boolean): Promise<SDK.RemoteObject.GetPropertiesResult> {
    const {objectId} = this.extensionObject;
    if (objectId) {
      assertNotNullOrUndefined(this.plugin.getProperties);
      const extensionObjectProperties = await this.plugin.getProperties(objectId);
      const properties = await Promise.all(extensionObjectProperties.map(
          async p => new SDK.RemoteObject.RemoteObjectProperty(
              p.name, await wrapRemoteObject(this.callFrame, p.value, this.plugin))));
      return {properties, internalProperties: null};
    }

    return {properties: null, internalProperties: null};
  }

  override release(): void {
    const {objectId} = this.extensionObject;
    if (objectId) {
      assertNotNullOrUndefined(this.plugin.releaseObject);
      void this.plugin.releaseObject(objectId);
    }
  }

  override debuggerModel(): SDK.DebuggerModel.DebuggerModel {
    return this.callFrame.debuggerModel;
  }

  override runtimeModel(): SDK.RuntimeModel.RuntimeModel {
    return this.callFrame.debuggerModel.runtimeModel();
  }

  override isLinearMemoryInspectable(): boolean {
    return this.extensionObject.linearMemoryAddress !== undefined;
  }
}

export type StopId = bigint;
export class DebuggerLanguagePluginManager implements
    SDK.TargetManager.SDKModelObserver<SDK.DebuggerModel.DebuggerModel> {
  readonly #workspace: Workspace.Workspace.WorkspaceImpl;
  readonly #debuggerWorkspaceBinding: DebuggerWorkspaceBinding;
  #plugins: DebuggerLanguagePlugin[];
  readonly #debuggerModelToData: Map<SDK.DebuggerModel.DebuggerModel, ModelData>;
  readonly #rawModuleHandles: Map<string, {
    rawModuleId: string,
    plugin: DebuggerLanguagePlugin,
    scripts: SDK.Script.Script[],
    addRawModulePromise:
        Promise<Platform.DevToolsPath.UrlString[]|{missingSymbolFiles: SDK.DebuggerModel.MissingDebugFiles[]}>,
  }>;
  private readonly callFrameByStopId = new Map<StopId, SDK.DebuggerModel.CallFrame>();
  private readonly stopIdByCallFrame = new Map<SDK.DebuggerModel.CallFrame, StopId>();
  private nextStopId: StopId = 0n;

  constructor(
      targetManager: SDK.TargetManager.TargetManager, workspace: Workspace.Workspace.WorkspaceImpl,
      debuggerWorkspaceBinding: DebuggerWorkspaceBinding) {
    this.#workspace = workspace;
    this.#debuggerWorkspaceBinding = debuggerWorkspaceBinding;

    this.#plugins = [];

    this.#debuggerModelToData = new Map();
    targetManager.observeModels(SDK.DebuggerModel.DebuggerModel, this);

    this.#rawModuleHandles = new Map();
  }

  private async evaluateOnCallFrame(
      callFrame: SDK.DebuggerModel.CallFrame, options: SDK.RuntimeModel.EvaluationOptions): Promise<{
    object: SDK.RemoteObject.RemoteObject,
    exceptionDetails?: Protocol.Runtime.ExceptionDetails,
  }|{
    error: string,
  }|null> {
    const {script} = callFrame;
    const {expression, returnByValue, throwOnSideEffect} = options;
    const {plugin} = await this.rawModuleIdAndPluginForScript(script);
    if (!plugin) {
      return null;
    }
    const location = getRawLocation(callFrame);
    const sourceLocations = await plugin.rawLocationToSourceLocation(location);
    if (sourceLocations.length === 0) {
      return null;
    }

    if (returnByValue) {
      return {error: 'Cannot return by value'};
    }
    if (throwOnSideEffect) {
      return {error: 'Cannot guarantee side-effect freedom'};
    }

    try {
      const object = await plugin.evaluate(expression, location, this.stopIdForCallFrame(callFrame));
      if (object) {
        return {object: await wrapRemoteObject(callFrame, object, plugin)};
      }
      return {object: new SDK.RemoteObject.LocalJSONObject(undefined)};
    } catch (error) {
      if (error instanceof FormattingError) {
        const {exception: object, exceptionDetails} = error;
        return {object, exceptionDetails};
      }
      const {exception: object, exceptionDetails} = FormattingError.makeLocal(callFrame, error.message);
      return {object, exceptionDetails};
    }
  }

  stopIdForCallFrame(callFrame: SDK.DebuggerModel.CallFrame): StopId {
    let stopId = this.stopIdByCallFrame.get(callFrame);
    if (stopId !== undefined) {
      return stopId;
    }

    stopId = this.nextStopId++;
    this.stopIdByCallFrame.set(callFrame, stopId);
    this.callFrameByStopId.set(stopId, callFrame);
    return stopId;
  }

  callFrameForStopId(stopId: StopId): SDK.DebuggerModel.CallFrame|undefined {
    return this.callFrameByStopId.get(stopId);
  }

  modelAdded(debuggerModel: SDK.DebuggerModel.DebuggerModel): void {
    this.#debuggerModelToData.set(debuggerModel, new ModelData(debuggerModel, this.#workspace));
    debuggerModel.addEventListener(SDK.DebuggerModel.Events.GlobalObjectCleared, this.globalObjectCleared, this);
    debuggerModel.addEventListener(SDK.DebuggerModel.Events.ParsedScriptSource, this.parsedScriptSource, this);
    debuggerModel.addEventListener(SDK.DebuggerModel.Events.DebuggerResumed, this.debuggerResumed, this);
    debuggerModel.setEvaluateOnCallFrameCallback(this.evaluateOnCallFrame.bind(this));
  }

  modelRemoved(debuggerModel: SDK.DebuggerModel.DebuggerModel): void {
    debuggerModel.removeEventListener(SDK.DebuggerModel.Events.GlobalObjectCleared, this.globalObjectCleared, this);
    debuggerModel.removeEventListener(SDK.DebuggerModel.Events.ParsedScriptSource, this.parsedScriptSource, this);
    debuggerModel.removeEventListener(SDK.DebuggerModel.Events.DebuggerResumed, this.debuggerResumed, this);
    debuggerModel.setEvaluateOnCallFrameCallback(null);
    const modelData = this.#debuggerModelToData.get(debuggerModel);
    if (modelData) {
      modelData.dispose();
      this.#debuggerModelToData.delete(debuggerModel);
    }
    this.#rawModuleHandles.forEach((rawModuleHandle, rawModuleId) => {
      const scripts = rawModuleHandle.scripts.filter(script => script.debuggerModel !== debuggerModel);
      if (scripts.length === 0) {
        rawModuleHandle.plugin.removeRawModule(rawModuleId).catch(error => {
          Common.Console.Console.instance().error(
              i18nString(UIStrings.errorInDebuggerLanguagePlugin, {PH1: error.message}), /* show=*/ false);
        });
        this.#rawModuleHandles.delete(rawModuleId);
      } else {
        rawModuleHandle.scripts = scripts;
      }
    });
  }

  private globalObjectCleared(event: Common.EventTarget.EventTargetEvent<SDK.DebuggerModel.DebuggerModel>): void {
    const debuggerModel = event.data;
    this.modelRemoved(debuggerModel);
    this.modelAdded(debuggerModel);
  }

  addPlugin(plugin: DebuggerLanguagePlugin): void {
    this.#plugins.push(plugin);
    for (const debuggerModel of this.#debuggerModelToData.keys()) {
      for (const script of debuggerModel.scripts()) {
        if (this.hasPluginForScript(script)) {
          continue;
        }
        this.parsedScriptSource({data: script});
      }
    }
  }

  removePlugin(plugin: DebuggerLanguagePlugin): void {
    this.#plugins = this.#plugins.filter(p => p !== plugin);
    const scripts = new Set<SDK.Script.Script>();
    this.#rawModuleHandles.forEach((rawModuleHandle, rawModuleId) => {
      if (rawModuleHandle.plugin !== plugin) {
        return;
      }
      rawModuleHandle.scripts.forEach(script => scripts.add(script));
      this.#rawModuleHandles.delete(rawModuleId);
    });
    for (const script of scripts) {
      const modelData = (this.#debuggerModelToData.get(script.debuggerModel) as ModelData);
      modelData.removeScript(script);

      // Let's see if we have another #plugin that's happy to
      // take this orphaned script now. This is important to
      // get right, since the same #plugin might race during
      // unregister/register and we might already have the
      // new instance of the #plugin added before we remove
      // the previous instance.
      this.parsedScriptSource({data: script});
    }
  }

  hasPluginForScript(script: SDK.Script.Script): boolean {
    const rawModuleId = rawModuleIdForScript(script);
    const rawModuleHandle = this.#rawModuleHandles.get(rawModuleId);
    return rawModuleHandle?.scripts.includes(script) ?? false;
  }

  /**
   * Returns the responsible language #plugin and the raw module ID for a script.
   *
   * This ensures that the `addRawModule` call finishes first such that the
   * caller can immediately issue calls to the returned #plugin without the
   * risk of racing with the `addRawModule` call. The returned #plugin will be
   * set to undefined to indicate that there's no #plugin for the script.
   */
  private async rawModuleIdAndPluginForScript(script: SDK.Script.Script): Promise<{
    rawModuleId: string,
    plugin: DebuggerLanguagePlugin|null,
  }> {
    const rawModuleId = rawModuleIdForScript(script);
    const rawModuleHandle = this.#rawModuleHandles.get(rawModuleId);
    if (rawModuleHandle) {
      await rawModuleHandle.addRawModulePromise;
      if (rawModuleHandle === this.#rawModuleHandles.get(rawModuleId)) {
        return {rawModuleId, plugin: rawModuleHandle.plugin};
      }
    }
    return {rawModuleId, plugin: null};
  }

  uiSourceCodeForURL(debuggerModel: SDK.DebuggerModel.DebuggerModel, url: Platform.DevToolsPath.UrlString):
      Workspace.UISourceCode.UISourceCode|null {
    const modelData = this.#debuggerModelToData.get(debuggerModel);
    if (modelData) {
      return modelData.getProject().uiSourceCodeForURL(url);
    }
    return null;
  }

  async rawLocationToUILocation(rawLocation: SDK.DebuggerModel.Location):
      Promise<Workspace.UISourceCode.UILocation|null> {
    const script = rawLocation.script();
    if (!script) {
      return null;
    }
    const {rawModuleId, plugin} = await this.rawModuleIdAndPluginForScript(script);
    if (!plugin) {
      return null;
    }

    const pluginLocation = {
      rawModuleId,
      // RawLocation.#columnNumber is the byte offset in the full raw wasm module. Plugins expect the offset in the code
      // section, so subtract the offset of the code section in the module here.
      codeOffset: rawLocation.columnNumber - (script.codeOffset() || 0),
      inlineFrameIndex: rawLocation.inlineFrameIndex,
    };

    try {
      const sourceLocations = await plugin.rawLocationToSourceLocation(pluginLocation);
      for (const sourceLocation of sourceLocations) {
        const uiSourceCode = this.uiSourceCodeForURL(
            script.debuggerModel, sourceLocation.sourceFileURL as Platform.DevToolsPath.UrlString);
        if (!uiSourceCode) {
          continue;
        }
        // Absence of column information is indicated by the value `-1` in talking to language #plugins.
        return uiSourceCode.uiLocation(
            sourceLocation.lineNumber, sourceLocation.columnNumber >= 0 ? sourceLocation.columnNumber : undefined);
      }
    } catch (error) {
      Common.Console.Console.instance().error(
          i18nString(UIStrings.errorInDebuggerLanguagePlugin, {PH1: error.message}), /* show=*/ false);
    }
    return null;
  }

  uiLocationToRawLocationRanges(
      uiSourceCode: Workspace.UISourceCode.UISourceCode, lineNumber: number,
      columnNumber: number|undefined = -1): Promise<Array<{
    start: SDK.DebuggerModel.Location,
    end: SDK.DebuggerModel.Location,
  }>|null> {
    const locationPromises: Array<Promise<Array<{
      start: SDK.DebuggerModel.Location,
      end: SDK.DebuggerModel.Location,
    }>>> = [];
    this.scriptsForUISourceCode(uiSourceCode).forEach(script => {
      const rawModuleId = rawModuleIdForScript(script);
      const rawModuleHandle = this.#rawModuleHandles.get(rawModuleId);
      if (!rawModuleHandle) {
        return;
      }
      const {plugin} = rawModuleHandle;
      locationPromises.push(getLocations(rawModuleId, plugin, script));
    });
    if (locationPromises.length === 0) {
      return Promise.resolve(null);
    }

    return Promise.all(locationPromises).then(locations => locations.flat()).catch(error => {
      Common.Console.Console.instance().error(
          i18nString(UIStrings.errorInDebuggerLanguagePlugin, {PH1: error.message}), /* show=*/ false);
      return null;
    });

    async function getLocations(
        rawModuleId: string, plugin: DebuggerLanguagePlugin, script: SDK.Script.Script): Promise<Array<{
      start: SDK.DebuggerModel.Location,
      end: SDK.DebuggerModel.Location,
    }>> {
      const pluginLocation = {rawModuleId, sourceFileURL: uiSourceCode.url(), lineNumber, columnNumber};

      const rawLocations = await plugin.sourceLocationToRawLocation(pluginLocation);
      if (!rawLocations) {
        return [];
      }
      return rawLocations.map(
          m => ({
            start: new SDK.DebuggerModel.Location(
                script.debuggerModel, script.scriptId, 0, Number(m.startOffset) + (script.codeOffset() || 0)),
            end: new SDK.DebuggerModel.Location(
                script.debuggerModel, script.scriptId, 0, Number(m.endOffset) + (script.codeOffset() || 0)),
          }));
    }
  }

  async uiLocationToRawLocations(
      uiSourceCode: Workspace.UISourceCode.UISourceCode, lineNumber: number,
      columnNumber?: number): Promise<SDK.DebuggerModel.Location[]|null> {
    const locationRanges = await this.uiLocationToRawLocationRanges(uiSourceCode, lineNumber, columnNumber);
    if (!locationRanges) {
      return null;
    }
    return locationRanges.map(({start}) => start);
  }

  async uiLocationRangeToRawLocationRanges(
      uiSourceCode: Workspace.UISourceCode.UISourceCode,
      textRange: TextUtils.TextRange.TextRange): Promise<SDK.DebuggerModel.LocationRange[]|null> {
    const locationRangesPromises = [];
    for (let line = textRange.startLine; line <= textRange.endLine; ++line) {
      locationRangesPromises.push(this.uiLocationToRawLocationRanges(uiSourceCode, line));
    }
    const ranges = [];
    for (const locationRanges of await Promise.all(locationRangesPromises)) {
      if (locationRanges === null) {
        return null;
      }
      for (const range of locationRanges) {
        const [startLocation, endLocation] = await Promise.all([
          this.rawLocationToUILocation(range.start),
          this.rawLocationToUILocation(range.end),
        ]);
        if (startLocation === null || endLocation === null) {
          continue;
        }
        // Report all ranges that somehow intersect with the `textRange`. It's the
        // responsibility of the caller to filter / clamp these ranges appropriately.
        const overlap = textRange.intersection(new TextUtils.TextRange.TextRange(
            startLocation.lineNumber, startLocation.columnNumber ?? 0, endLocation.lineNumber,
            endLocation.columnNumber ?? Infinity));
        if (!overlap.isEmpty()) {
          ranges.push(range);
        }
      }
    }
    return ranges;
  }

  async translateRawFramesStep(
      rawFrames: StackTraceImpl.Trie.RawFrame[],
      translatedFrames: Awaited<ReturnType<StackTraceImpl.StackTraceModel.TranslateRawFrames>>,
      target: SDK.Target.Target): Promise<boolean> {
    const frame = rawFrames[0];
    const script = target.model(SDK.DebuggerModel.DebuggerModel)?.scriptForId(frame.scriptId ?? '');
    if (!script) {
      return false;
    }

    const functionInfo = await this.getFunctionInfo(script, frame);
    if (!functionInfo) {
      return false;
    }

    // The plugin is responsible for translating this frame. The only question is whether it was successful,
    // or if we identity map the raw frame and attach the "missing debug info details".
    rawFrames.shift();

    if ('frames' in functionInfo && functionInfo.frames.length) {
      const framePromises = functionInfo.frames.map(async ({name}, index) => {
        const rawLocation = new SDK.DebuggerModel.Location(
            script.debuggerModel, script.scriptId, frame.lineNumber, frame.columnNumber, index);
        const uiLocation = await this.rawLocationToUILocation(rawLocation);
        return translatedFromUILocation(uiLocation, name, frame);
      });

      translatedFrames.push(await Promise.all(framePromises));
      return true;
    }

    // Translate the location only. We go through via "DebuggerWorkspaceBinding". It'll still try the plugin
    // first, but this way, we'll get a UISourceCode for the raw script if the plugin fails to translate.
    const uiLocation = await this.#debuggerWorkspaceBinding.rawLocationToUILocation(
        new SDK.DebuggerModel.Location(script.debuggerModel, script.scriptId, frame.lineNumber, frame.columnNumber));
    const mappedFrame = translatedFromUILocation(uiLocation, frame.functionName, frame);

    if ('missingSymbolFiles' in functionInfo && functionInfo.missingSymbolFiles.length) {
      translatedFrames.push([{
        ...mappedFrame,
        missingDebugInfo: {
          type: StackTrace.StackTrace.MissingDebugInfoType.PARTIAL_INFO,
          missingDebugFiles: functionInfo.missingSymbolFiles,
        },
      }]);
    } else {
      translatedFrames.push([{
        ...mappedFrame,
        missingDebugInfo: {
          type: StackTrace.StackTrace.MissingDebugInfoType.NO_INFO,
        },
      }]);
    }

    return true;

    function translatedFromUILocation(
        uiLocation: Workspace.UISourceCode.UILocation|null, name: string|undefined,
        fallback: StackTraceImpl.Trie.RawFrame): (typeof translatedFrames)[number][number] {
      if (uiLocation) {
        return {
          uiSourceCode: uiLocation.uiSourceCode,
          name,
          line: uiLocation.lineNumber,
          column: uiLocation.columnNumber ?? -1,
        };
      }
      return {
        url: fallback.url,
        name: fallback.functionName,
        line: fallback.lineNumber,
        column: fallback.columnNumber,
      };
    }
  }

  scriptsForUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): SDK.Script.Script[] {
    for (const modelData of this.#debuggerModelToData.values()) {
      const scripts = modelData.uiSourceCodeToScripts.get(uiSourceCode);
      if (scripts) {
        return scripts;
      }
    }
    return [];
  }

  setDebugInfoURL(script: SDK.Script.Script, externalURL: Platform.DevToolsPath.UrlString): void {
    if (this.hasPluginForScript(script)) {
      return;
    }
    script.debugSymbols = {type: Protocol.Debugger.DebugSymbolsType.ExternalDWARF, externalURL};
    this.parsedScriptSource({data: script});
    void script.debuggerModel.setDebugInfoURL(script, externalURL);
  }

  private parsedScriptSource(event: Common.EventTarget.EventTargetEvent<SDK.Script.Script>): void {
    const script = event.data;
    if (!script.sourceURL) {
      return;
    }

    for (const plugin of this.#plugins) {
      if (!plugin.handleScript(script)) {
        continue;
      }
      const rawModuleId = rawModuleIdForScript(script);
      let rawModuleHandle = this.#rawModuleHandles.get(rawModuleId);
      if (!rawModuleHandle) {
        const sourceFileURLsPromise = (async () => {
          const console = Common.Console.Console.instance();
          const url = script.sourceURL;
          const symbolsUrl = (script.debugSymbols?.externalURL) || '';
          if (symbolsUrl) {
            console.log(i18nString(UIStrings.loadingDebugSymbolsForVia, {PH1: plugin.name, PH2: url, PH3: symbolsUrl}));
          } else {
            console.log(i18nString(UIStrings.loadingDebugSymbolsFor, {PH1: plugin.name, PH2: url}));
          }
          try {
            const code =
                (!symbolsUrl && Common.ParsedURL.schemeIs(url, 'wasm:')) ? await script.getWasmBytecode() : undefined;
            const addModuleResult = await plugin.addRawModule(rawModuleId, symbolsUrl, {url, code});
            // Check that the handle isn't stale by now. This works because the code that assigns to
            // `rawModuleHandle` below will run before this code because of the `await` in the preceding
            // line. This is primarily to avoid logging the message below, which would give the developer
            // the misleading information that we're done, while in reality it was a stale call that finished.
            if (rawModuleHandle !== this.#rawModuleHandles.get(rawModuleId)) {
              return [];
            }
            if ('missingSymbolFiles' in addModuleResult) {
              const initiator = plugin.createPageResourceLoadInitiator();
              const missingSymbolFiles = addModuleResult.missingSymbolFiles.map(resource => {
                const resourceUrl = resource as Platform.DevToolsPath.UrlString;
                return {resourceUrl, initiator};
              });
              return {missingSymbolFiles};
            }
            const sourceFileURLs = addModuleResult as Platform.DevToolsPath.UrlString[];
            if (sourceFileURLs.length === 0) {
              console.warn(i18nString(UIStrings.loadedDebugSymbolsForButDidnt, {PH1: plugin.name, PH2: url}));
            } else {
              console.log(i18nString(
                  UIStrings.loadedDebugSymbolsForFound, {PH1: plugin.name, PH2: url, PH3: sourceFileURLs.length}));
            }
            return sourceFileURLs;
          } catch (error) {
            console.error(
                i18nString(UIStrings.failedToLoadDebugSymbolsFor, {PH1: plugin.name, PH2: url, PH3: error.message}),
                /* show=*/ false);
            this.#rawModuleHandles.delete(rawModuleId);
            return [];
          }
        })();
        rawModuleHandle = {rawModuleId, plugin, scripts: [script], addRawModulePromise: sourceFileURLsPromise};
        this.#rawModuleHandles.set(rawModuleId, rawModuleHandle);
      } else {
        rawModuleHandle.scripts.push(script);
      }

      // Wait for the addRawModule call to finish and
      // update the #project. It's important to check
      // for the DebuggerModel again, which may disappear
      // in the meantime...
      void rawModuleHandle.addRawModulePromise.then(sourceFileURLs => {
        // The script might have disappeared meanwhile...
        if (script.debuggerModel.scriptForId(script.scriptId) === script) {
          const modelData = this.#debuggerModelToData.get(script.debuggerModel);
          if (modelData && Array.isArray(sourceFileURLs)) {  // The DebuggerModel could have disappeared meanwhile...
            modelData.addSourceFiles(script, sourceFileURLs);
          }
          void this.#debuggerWorkspaceBinding.updateLocations(script);
        }
      });
      return;
    }
  }

  private debuggerResumed(event: Common.EventTarget.EventTargetEvent<SDK.DebuggerModel.DebuggerModel>): void {
    const resumedFrames =
        Array.from(this.callFrameByStopId.values()).filter(callFrame => callFrame.debuggerModel === event.data);
    for (const callFrame of resumedFrames) {
      const stopId = this.stopIdByCallFrame.get(callFrame);
      assertNotNullOrUndefined(stopId);
      this.stopIdByCallFrame.delete(callFrame);
      this.callFrameByStopId.delete(stopId);
    }
  }

  getSourcesForScript(script: SDK.Script.Script):
      Promise<Platform.DevToolsPath.UrlString[]|{missingSymbolFiles: SDK.DebuggerModel.MissingDebugFiles[]}|undefined> {
    const rawModuleId = rawModuleIdForScript(script);
    const rawModuleHandle = this.#rawModuleHandles.get(rawModuleId);
    if (rawModuleHandle) {
      return rawModuleHandle.addRawModulePromise;
    }
    return Promise.resolve(undefined);
  }

  async resolveScopeChain(callFrame: SDK.DebuggerModel.CallFrame): Promise<SourceScope[]|null> {
    const script = callFrame.script;
    const {rawModuleId, plugin} = await this.rawModuleIdAndPluginForScript(script);
    if (!plugin) {
      return null;
    }

    const location = {
      rawModuleId,
      codeOffset: callFrame.location().columnNumber - (script.codeOffset() || 0),
      inlineFrameIndex: callFrame.inlineFrameIndex,
    };

    const stopId = this.stopIdForCallFrame(callFrame);

    try {
      const sourceMapping = await plugin.rawLocationToSourceLocation(location);
      if (sourceMapping.length === 0) {
        return null;
      }
      const scopes = new Map<string, SourceScope>();
      const variables = await plugin.listVariablesInScope(location);
      for (const variable of variables || []) {
        let scope = scopes.get(variable.scope);
        if (!scope) {
          const {type, typeName, icon} = await plugin.getScopeInfo(variable.scope);
          scope = new SourceScope(callFrame, stopId, type, typeName, icon, plugin);
          scopes.set(variable.scope, scope);
        }
        scope.object().variables.push(variable);
      }
      return Array.from(scopes.values());
    } catch (error) {
      Common.Console.Console.instance().error(
          i18nString(UIStrings.errorInDebuggerLanguagePlugin, {PH1: error.message}), /* show=*/ false);
      return null;
    }
  }

  async getFunctionInfo(script: SDK.Script.Script, location: Pick<SDK.DebuggerModel.Location, 'columnNumber'>):
      Promise<{frames: Chrome.DevTools.FunctionInfo[], missingSymbolFiles: SDK.DebuggerModel.MissingDebugFiles[]}|
              {frames: Chrome.DevTools.FunctionInfo[]}|{missingSymbolFiles: SDK.DebuggerModel.MissingDebugFiles[]}|
              null> {
    const {rawModuleId, plugin} = await this.rawModuleIdAndPluginForScript(script);
    if (!plugin) {
      return null;
    }

    const rawLocation: Chrome.DevTools.RawLocation = {
      rawModuleId,
      codeOffset: location.columnNumber - (script.codeOffset() || 0),
      inlineFrameIndex: 0,
    };

    try {
      const functionInfo = await plugin.getFunctionInfo(rawLocation);
      if ('missingSymbolFiles' in functionInfo) {
        const initiator = plugin.createPageResourceLoadInitiator();
        const missingSymbolFiles = functionInfo.missingSymbolFiles.map(resource => {
          const resourceUrl = resource as Platform.DevToolsPath.UrlString;
          return {resourceUrl, initiator};
        });
        return {missingSymbolFiles, ...('frames' in functionInfo && {frames: functionInfo.frames})};
      }
      return functionInfo;
    } catch (error) {
      Common.Console.Console.instance().warn(i18nString(UIStrings.errorInDebuggerLanguagePlugin, {PH1: error.message}));
      return {frames: []};
    }
  }

  async getInlinedFunctionRanges(rawLocation: SDK.DebuggerModel.Location): Promise<Array<{
    start: SDK.DebuggerModel.Location,
    end: SDK.DebuggerModel.Location,
  }>> {
    const script = rawLocation.script();
    if (!script) {
      return [];
    }
    const {rawModuleId, plugin} = await this.rawModuleIdAndPluginForScript(script);
    if (!plugin) {
      return [];
    }

    const pluginLocation = {
      rawModuleId,
      // RawLocation.#columnNumber is the byte offset in the full raw wasm module. Plugins expect the offset in the code
      // section, so subtract the offset of the code section in the module here.
      codeOffset: rawLocation.columnNumber - (script.codeOffset() || 0),
    };

    try {
      // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration)
      // @ts-expect-error
      const locations = await plugin.getInlinedFunctionRanges(pluginLocation);
      return locations.map(
          m => ({
            start: new SDK.DebuggerModel.Location(
                script.debuggerModel, script.scriptId, 0, Number(m.startOffset) + (script.codeOffset() || 0)),
            end: new SDK.DebuggerModel.Location(
                script.debuggerModel, script.scriptId, 0, Number(m.endOffset) + (script.codeOffset() || 0)),
          }));
    } catch (error) {
      Common.Console.Console.instance().warn(i18nString(UIStrings.errorInDebuggerLanguagePlugin, {PH1: error.message}));
      return [];
    }
  }

  async getInlinedCalleesRanges(rawLocation: SDK.DebuggerModel.Location): Promise<Array<{
    start: SDK.DebuggerModel.Location,
    end: SDK.DebuggerModel.Location,
  }>> {
    const script = rawLocation.script();
    if (!script) {
      return [];
    }
    const {rawModuleId, plugin} = await this.rawModuleIdAndPluginForScript(script);
    if (!plugin) {
      return [];
    }

    const pluginLocation = {
      rawModuleId,
      // RawLocation.#columnNumber is the byte offset in the full raw wasm module. Plugins expect the offset in the code
      // section, so subtract the offset of the code section in the module here.
      codeOffset: rawLocation.columnNumber - (script.codeOffset() || 0),
    };

    try {
      // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration)
      // @ts-expect-error
      const locations = await plugin.getInlinedCalleesRanges(pluginLocation);
      return locations.map(
          m => ({
            start: new SDK.DebuggerModel.Location(
                script.debuggerModel, script.scriptId, 0, Number(m.startOffset) + (script.codeOffset() || 0)),
            end: new SDK.DebuggerModel.Location(
                script.debuggerModel, script.scriptId, 0, Number(m.endOffset) + (script.codeOffset() || 0)),
          }));
    } catch (error) {
      Common.Console.Console.instance().warn(i18nString(UIStrings.errorInDebuggerLanguagePlugin, {PH1: error.message}));
      return [];
    }
  }

  async getMappedLines(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<Set<number>|null> {
    const rawModuleIds =
        await Promise.all(this.scriptsForUISourceCode(uiSourceCode).map(s => this.rawModuleIdAndPluginForScript(s)));

    let mappedLines: Set<number>|null = null;
    for (const {rawModuleId, plugin} of rawModuleIds) {
      if (!plugin) {
        continue;
      }
      const lines = await plugin.getMappedLines(rawModuleId, uiSourceCode.url());

      if (lines === undefined) {
        continue;
      }
      if (mappedLines === null) {
        mappedLines = new Set(lines);
      } else {
        lines.forEach(l => (mappedLines as Set<number>).add(l));
      }
    }
    return mappedLines;
  }
}

class ModelData {
  project: ContentProviderBasedProject;
  readonly uiSourceCodeToScripts: Map<Workspace.UISourceCode.UISourceCode, SDK.Script.Script[]>;
  constructor(debuggerModel: SDK.DebuggerModel.DebuggerModel, workspace: Workspace.Workspace.WorkspaceImpl) {
    this.project = new ContentProviderBasedProject(
        workspace, 'language_plugins::' + debuggerModel.target().id(), Workspace.Workspace.projectTypes.Network, '',
        false /* isServiceProject */);
    NetworkProject.setTargetForProject(this.project, debuggerModel.target());

    this.uiSourceCodeToScripts = new Map();
  }

  addSourceFiles(script: SDK.Script.Script, urls: Platform.DevToolsPath.UrlString[]): void {
    const initiator = script.createPageResourceLoadInitiator();
    for (const url of urls) {
      let uiSourceCode = this.project.uiSourceCodeForURL(url);
      if (!uiSourceCode) {
        uiSourceCode = this.project.createUISourceCode(url, Common.ResourceType.resourceTypes.SourceMapScript);
        NetworkProject.setInitialFrameAttribution(uiSourceCode, script.frameId);

        // Bind the uiSourceCode to the script first before we add the
        // uiSourceCode to the #project and thereby notify the rest of
        // the system about the new source file.
        // https://crbug.com/1150295 is an example where the breakpoint
        // resolution logic kicks in right after adding the uiSourceCode
        // and at that point we already need to have the mapping in place
        // otherwise we will not get the breakpoint right.
        this.uiSourceCodeToScripts.set(uiSourceCode, [script]);

        const contentProvider = new SDK.CompilerSourceMappingContentProvider.CompilerSourceMappingContentProvider(
            url, Common.ResourceType.resourceTypes.SourceMapScript, initiator);
        const mimeType = Common.ResourceType.ResourceType.mimeFromURL(url) || 'text/javascript';
        this.project.addUISourceCodeWithProvider(uiSourceCode, contentProvider, null, mimeType);
      } else {
        // The same uiSourceCode can be provided by different scripts,
        // but we don't expect that to happen frequently.
        const scripts = (this.uiSourceCodeToScripts.get(uiSourceCode) as SDK.Script.Script[]);
        if (!scripts.includes(script)) {
          scripts.push(script);
        }
      }
    }
  }

  removeScript(script: SDK.Script.Script): void {
    this.uiSourceCodeToScripts.forEach((scripts, uiSourceCode) => {
      scripts = scripts.filter(s => s !== script);
      if (scripts.length === 0) {
        this.uiSourceCodeToScripts.delete(uiSourceCode);
        this.project.removeUISourceCode(uiSourceCode.url());
      } else {
        this.uiSourceCodeToScripts.set(uiSourceCode, scripts);
      }
    });
  }

  dispose(): void {
    this.project.dispose();
  }

  getProject(): ContentProviderBasedProject {
    return this.project;
  }
}

export interface DebuggerLanguagePlugin extends Chrome.DevTools.LanguageExtensionPlugin {
  name: string;
  handleScript(script: SDK.Script.Script): boolean;
  createPageResourceLoadInitiator(): SDK.PageResourceLoader.PageResourceLoadInitiator;
}
