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

import * as Common from '../../core/common/common.js';
import type * as Platform from '../../core/platform/platform.js';
import {assertNotNullOrUndefined} from '../../core/platform/platform.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import * as Bindings from '../bindings/bindings.js';
import * as Formatter from '../formatter/formatter.js';
import * as SourceMapScopes from '../source_map_scopes/source_map_scopes.js';
import type * as TextUtils from '../text_utils/text_utils.js';
import * as Workspace from '../workspace/workspace.js';

let breakpointManagerInstance: BreakpointManager;
const INITIAL_RESTORE_BREAKPOINT_COUNT = 100;

export class BreakpointManager extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements
    SDK.TargetManager.SDKModelObserver<SDK.DebuggerModel.DebuggerModel> {
  readonly storage: Storage;
  readonly #workspace: Workspace.Workspace.WorkspaceImpl;
  readonly targetManager: SDK.TargetManager.TargetManager;
  readonly debuggerWorkspaceBinding: Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding;
  // For each source code, we remember the list or breakpoints that refer to that UI source code as
  // their home UI source code. This is necessary to correctly remove the UI source code from
  // breakpoints upon receiving the UISourceCodeRemoved event.
  readonly #breakpointsForHomeUISourceCode = new Map<Workspace.UISourceCode.UISourceCode, Set<Breakpoint>>();
  // Mapping of UI source codes to all the current breakpoint UI locations. For bound breakpoints,
  // this is all the locations where the breakpoints was bound. For the unbound breakpoints,
  // this is the default locations in the home UI source codes.
  readonly #breakpointsForUISourceCode =
      new Map<Workspace.UISourceCode.UISourceCode, Map<string, BreakpointLocation>>();
  readonly #breakpointByStorageId = new Map<string, Breakpoint>();
  #updateBindingsCallbacks: Array<(uiSourceCode: Workspace.UISourceCode.UISourceCode) => Promise<void>> = [];

  private constructor(
      targetManager: SDK.TargetManager.TargetManager, workspace: Workspace.Workspace.WorkspaceImpl,
      debuggerWorkspaceBinding: Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding,
      settings: Common.Settings.Settings, restoreInitialBreakpointCount?: number) {
    super();
    this.#workspace = workspace;
    this.targetManager = targetManager;
    this.debuggerWorkspaceBinding = debuggerWorkspaceBinding;
    this.storage = new Storage(settings);

    this.storage.mute();
    this.#setInitialBreakpoints(restoreInitialBreakpointCount ?? INITIAL_RESTORE_BREAKPOINT_COUNT);
    this.storage.unmute();

    this.#workspace.addEventListener(Workspace.Workspace.Events.UISourceCodeAdded, this.uiSourceCodeAdded, this);
    this.#workspace.addEventListener(Workspace.Workspace.Events.UISourceCodeRemoved, this.uiSourceCodeRemoved, this);
    this.#workspace.addEventListener(Workspace.Workspace.Events.ProjectRemoved, this.projectRemoved, this);

    this.targetManager.observeModels(SDK.DebuggerModel.DebuggerModel, this);
  }

  #setInitialBreakpoints(restoreInitialBreakpointCount: number): void {
    let breakpointsToSkip = this.storage.breakpoints.size - restoreInitialBreakpointCount;
    for (const storageState of this.storage.breakpoints.values()) {
      if (breakpointsToSkip > 0) {
        breakpointsToSkip--;
        continue;
      }
      const storageId = Storage.computeId(storageState);
      const breakpoint = new Breakpoint(this, null, storageState, BreakpointOrigin.OTHER);
      this.#breakpointByStorageId.set(storageId, breakpoint);
    }
  }

  static instance(opts: {
    forceNew: boolean|null,
    targetManager: SDK.TargetManager.TargetManager|null,
    workspace: Workspace.Workspace.WorkspaceImpl|null,
    debuggerWorkspaceBinding: Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding|null,
    settings: Common.Settings.Settings|null,
    restoreInitialBreakpointCount?: number,
  } = {
    forceNew: null,
    targetManager: null,
    workspace: null,
    debuggerWorkspaceBinding: null,
    settings: null,
  }): BreakpointManager {
    const {forceNew, targetManager, workspace, debuggerWorkspaceBinding, settings, restoreInitialBreakpointCount} =
        opts;
    if (!breakpointManagerInstance || forceNew) {
      if (!targetManager || !workspace || !debuggerWorkspaceBinding || !settings) {
        throw new Error(
            `Unable to create settings: targetManager, workspace, debuggerWorkspaceBinding, and settings must be provided: ${
                new Error().stack}`);
      }

      breakpointManagerInstance = new BreakpointManager(
          targetManager, workspace, debuggerWorkspaceBinding, settings, restoreInitialBreakpointCount);
    }

    return breakpointManagerInstance;
  }

  modelAdded(debuggerModel: SDK.DebuggerModel.DebuggerModel): void {
    if (Root.Runtime.experiments.isEnabled(Root.ExperimentNames.ExperimentName.INSTRUMENTATION_BREAKPOINTS)) {
      debuggerModel.setSynchronizeBreakpointsCallback(this.restoreBreakpointsForScript.bind(this));
    }
  }

  modelRemoved(debuggerModel: SDK.DebuggerModel.DebuggerModel): void {
    debuggerModel.setSynchronizeBreakpointsCallback(null);
  }

  addUpdateBindingsCallback(callback: ((uiSourceCode: Workspace.UISourceCode.UISourceCode) => Promise<void>)): void {
    this.#updateBindingsCallbacks.push(callback);
  }

  async copyBreakpoints(
      fromSourceCode: Workspace.UISourceCode.UISourceCode,
      toSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    const toSourceCodeIsRemoved = toSourceCode.project().uiSourceCodeForURL(toSourceCode.url()) !== toSourceCode ||
        this.#workspace.project(toSourceCode.project().id()) !== toSourceCode.project();
    const breakpointItems = this.storage.breakpointItems(fromSourceCode.url(), fromSourceCode.contentType().name());
    for (const item of breakpointItems) {
      if (toSourceCodeIsRemoved) {
        // If the target source code has been detached from the workspace, then no breakpoint should refer
        // to that source code. Let us only update the storage, so that the breakpoints appear once
        // the user binds the file system again.
        this.storage.updateBreakpoint(
            {...item, url: toSourceCode.url(), resourceTypeName: toSourceCode.contentType().name()});
      } else {
        await this.setBreakpoint(
            toSourceCode, item.lineNumber, item.columnNumber, item.condition, item.enabled, item.isLogpoint,
            BreakpointOrigin.OTHER);
      }
    }
  }

  // This method explicitly awaits the source map (if necessary) and the uiSourceCodes
  // required to set all breakpoints that are related to this script.
  async restoreBreakpointsForScript(script: SDK.Script.Script): Promise<void> {
    if (!Root.Runtime.experiments.isEnabled(Root.ExperimentNames.ExperimentName.INSTRUMENTATION_BREAKPOINTS)) {
      return;
    }
    if (!script.sourceURL) {
      return;
    }

    const uiSourceCode = await this.getUISourceCodeWithUpdatedBreakpointInfo(script);
    if (this.#hasBreakpointsForUrl(script.sourceURL)) {
      await this.#restoreBreakpointsForUrl(uiSourceCode);
    }

    const debuggerModel = script.debuggerModel;
    // Handle source maps and the original sources.
    const sourceMap = await debuggerModel.sourceMapManager().sourceMapForClientPromise(script);
    if (sourceMap) {
      for (const sourceURL of sourceMap.sourceURLs()) {
        if (this.#hasBreakpointsForUrl(sourceURL)) {
          const uiSourceCode = await this.debuggerWorkspaceBinding.uiSourceCodeForSourceMapSourceURLPromise(
              debuggerModel, sourceURL, script.isContentScript());
          await this.#restoreBreakpointsForUrl(uiSourceCode);
        }
      }
    }

    // Handle language plugins
    const {pluginManager} = this.debuggerWorkspaceBinding;
    const sourceUrls = await pluginManager.getSourcesForScript(script);
    if (Array.isArray(sourceUrls)) {
      for (const sourceURL of sourceUrls) {
        if (this.#hasBreakpointsForUrl(sourceURL)) {
          const uiSourceCode =
              await this.debuggerWorkspaceBinding.uiSourceCodeForDebuggerLanguagePluginSourceURLPromise(
                  debuggerModel, sourceURL);
          assertNotNullOrUndefined(uiSourceCode);
          await this.#restoreBreakpointsForUrl(uiSourceCode);
        }
      }
    }
  }

  async getUISourceCodeWithUpdatedBreakpointInfo(script: SDK.Script.Script):
      Promise<Workspace.UISourceCode.UISourceCode> {
    const uiSourceCode = this.debuggerWorkspaceBinding.uiSourceCodeForScript(script);
    assertNotNullOrUndefined(uiSourceCode);
    await this.#updateBindings(uiSourceCode);
    return uiSourceCode;
  }

  async #updateBindings(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    if (this.#updateBindingsCallbacks.length > 0) {
      // It's possible to set breakpoints on files on the file system, and to have them
      // hit whenever we navigate to a page that serves that file.
      // To make sure that we have all breakpoint information moved from the file system
      // to the served file, we need to update the bindings and await it. This will
      // move the breakpoints from the FileSystem UISourceCode to the Network UiSourceCode.
      const promises = [];
      for (const callback of this.#updateBindingsCallbacks) {
        promises.push(callback(uiSourceCode));
      }
      await Promise.all(promises);
    }
  }

  async #restoreBreakpointsForUrl(uiSourceCode: Workspace.UISourceCode.UISourceCode): Promise<void> {
    this.restoreBreakpoints(uiSourceCode);
    const breakpoints = this.#breakpointByStorageId.values();
    const affectedBreakpoints = Array.from(breakpoints).filter(x => x.uiSourceCodes.has(uiSourceCode));
    // Make sure to properly await their updates
    await Promise.all(affectedBreakpoints.map(bp => bp.updateBreakpoint()));
  }

  #hasBreakpointsForUrl(url: Platform.DevToolsPath.UrlString): boolean {
    // We intentionally don't specify a resource type here, but just check
    // generally whether there's any breakpoint matching the given `url`.
    const breakpointItems = this.storage.breakpointItems(url);
    return breakpointItems.length > 0;
  }

  static getScriptForInlineUiSourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): SDK.Script.Script|null {
    const script = Bindings.DefaultScriptMapping.DefaultScriptMapping.scriptForUISourceCode(uiSourceCode);
    if (script && script.isInlineScript() && !script.hasSourceURL) {
      return script;
    }
    return null;
  }

  // For inline scripts, this function translates the line-column coordinates into the coordinates
  // of the embedding document. For other scripts, it just returns unchanged line-column.
  static breakpointLocationFromUiLocation(uiLocation: Workspace.UISourceCode.UILocation):
      {lineNumber: number, columnNumber: number|undefined} {
    const uiSourceCode = uiLocation.uiSourceCode;
    const script = BreakpointManager.getScriptForInlineUiSourceCode(uiSourceCode);
    const {lineNumber, columnNumber} = script ? script.relativeLocationToRawLocation(uiLocation) : uiLocation;
    return {lineNumber, columnNumber};
  }

  // For inline scripts, this function translates the line-column coordinates of the embedding
  // document into the coordinates of the script. Other UI source code coordinated are not
  // affected.
  static uiLocationFromBreakpointLocation(
      uiSourceCode: Workspace.UISourceCode.UISourceCode, lineNumber: number,
      columnNumber: number|undefined): Workspace.UISourceCode.UILocation {
    const script = BreakpointManager.getScriptForInlineUiSourceCode(uiSourceCode);
    if (script) {
      ({lineNumber, columnNumber} = script.rawLocationToRelativeLocation({lineNumber, columnNumber}));
    }
    return uiSourceCode.uiLocation(lineNumber, columnNumber);
  }

  // Returns true for if the given (raw) position is within the script or if the script
  // is null. This is used to filter breakpoints if a script is known.
  static isValidPositionInScript(lineNumber: number, columnNumber: number|undefined, script: SDK.Script.Script|null):
      boolean {
    if (!script) {
      return true;
    }
    if (lineNumber < script.lineOffset || lineNumber > script.endLine) {
      return false;
    }
    if (lineNumber === script.lineOffset && columnNumber && columnNumber < script.columnOffset) {
      return false;
    }
    if (lineNumber === script.endLine && (!columnNumber || columnNumber >= script.endColumn)) {
      return false;
    }
    return true;
  }

  private restoreBreakpoints(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
    const script = BreakpointManager.getScriptForInlineUiSourceCode(uiSourceCode);
    const url = script?.sourceURL ?? uiSourceCode.url();
    if (!url) {
      return;
    }
    const contentType = uiSourceCode.contentType();

    this.storage.mute();
    const breakpoints = this.storage.breakpointItems(url, contentType.name());
    for (const breakpoint of breakpoints) {
      const {lineNumber, columnNumber} = breakpoint;
      if (!BreakpointManager.isValidPositionInScript(lineNumber, columnNumber, script)) {
        continue;
      }
      this.#setBreakpoint(
          uiSourceCode, lineNumber, columnNumber, breakpoint.condition, breakpoint.enabled, breakpoint.isLogpoint,
          BreakpointOrigin.OTHER);
    }
    this.storage.unmute();
  }

  private uiSourceCodeAdded(event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>): void {
    const uiSourceCode = event.data;
    this.restoreBreakpoints(uiSourceCode);
  }

  private uiSourceCodeRemoved(event: Common.EventTarget.EventTargetEvent<Workspace.UISourceCode.UISourceCode>): void {
    const uiSourceCode = event.data;
    this.removeUISourceCode(uiSourceCode);
  }

  private projectRemoved(event: Common.EventTarget.EventTargetEvent<Workspace.Workspace.Project>): void {
    const project = event.data;
    for (const uiSourceCode of project.uiSourceCodes()) {
      this.removeUISourceCode(uiSourceCode);
    }
  }

  private removeUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
    const breakpoints = this.#getAllBreakpointsForUISourceCode(uiSourceCode);
    breakpoints.forEach(bp => bp.removeUISourceCode(uiSourceCode));
  }

  async setBreakpoint(
      uiSourceCode: Workspace.UISourceCode.UISourceCode, lineNumber: number, columnNumber: number|undefined,
      condition: UserCondition, enabled: boolean, isLogpoint: boolean,
      origin: BreakpointOrigin): Promise<Breakpoint|undefined> {
    // As part of de-duplication, we always only show one uiSourceCode, but we may
    // have several uiSourceCodes that correspond to the same
    // file (but are attached to different targets), so set a breakpoint on all of them.
    const compatibleUiSourceCodes = this.#workspace.findCompatibleUISourceCodes(uiSourceCode);

    let primaryBreakpoint: Breakpoint|undefined;
    for (const compatibleUiSourceCode of compatibleUiSourceCodes) {
      const uiLocation = new Workspace.UISourceCode.UILocation(compatibleUiSourceCode, lineNumber, columnNumber);
      const normalizedLocation = await this.debuggerWorkspaceBinding.normalizeUILocation(uiLocation);
      const breakpointLocation = BreakpointManager.breakpointLocationFromUiLocation(normalizedLocation);

      const breakpoint = this.#setBreakpoint(
          normalizedLocation.uiSourceCode, breakpointLocation.lineNumber, breakpointLocation.columnNumber, condition,
          enabled, isLogpoint, origin);

      if (uiSourceCode === compatibleUiSourceCode) {
        if (normalizedLocation.id() !== uiLocation.id()) {
          // Only call this on the uiSourceCode that was initially selected for breakpoint setting.
          void Common.Revealer.reveal(normalizedLocation);
        }
        primaryBreakpoint = breakpoint;
      }
    }

    console.assert(primaryBreakpoint !== undefined, 'The passed uiSourceCode is expected to be a valid uiSourceCode');
    return primaryBreakpoint;
  }

  #setBreakpoint(
      uiSourceCode: Workspace.UISourceCode.UISourceCode, lineNumber: number, columnNumber: number|undefined,
      condition: UserCondition, enabled: boolean, isLogpoint: boolean, origin: BreakpointOrigin): Breakpoint {
    const url = BreakpointManager.getScriptForInlineUiSourceCode(uiSourceCode)?.sourceURL ?? uiSourceCode.url();
    const resourceTypeName = uiSourceCode.contentType().name();
    const storageState = {url, resourceTypeName, lineNumber, columnNumber, condition, enabled, isLogpoint};
    const storageId = Storage.computeId(storageState);
    let breakpoint = this.#breakpointByStorageId.get(storageId);
    if (breakpoint) {
      breakpoint.updateState(storageState);
      breakpoint.addUISourceCode(uiSourceCode);
      void breakpoint.updateBreakpoint();
      return breakpoint;
    }
    breakpoint = new Breakpoint(this, uiSourceCode, storageState, origin);
    this.#breakpointByStorageId.set(storageId, breakpoint);
    return breakpoint;
  }

  findBreakpoint(uiLocation: Workspace.UISourceCode.UILocation): BreakpointLocation|null {
    const breakpoints = this.#breakpointsForUISourceCode.get(uiLocation.uiSourceCode);
    return breakpoints ? (breakpoints.get(uiLocation.id())) || null : null;
  }

  addHomeUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode, breakpoint: Breakpoint): void {
    let breakpoints = this.#breakpointsForHomeUISourceCode.get(uiSourceCode);
    if (!breakpoints) {
      breakpoints = new Set();
      this.#breakpointsForHomeUISourceCode.set(uiSourceCode, breakpoints);
    }
    breakpoints.add(breakpoint);
  }

  removeHomeUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode, breakpoint: Breakpoint): void {
    const breakpoints = this.#breakpointsForHomeUISourceCode.get(uiSourceCode);
    if (!breakpoints) {
      return;
    }
    breakpoints.delete(breakpoint);
    if (breakpoints.size === 0) {
      this.#breakpointsForHomeUISourceCode.delete(uiSourceCode);
    }
  }

  async possibleBreakpoints(
      uiSourceCode: Workspace.UISourceCode.UISourceCode,
      textRange: TextUtils.TextRange.TextRange): Promise<Workspace.UISourceCode.UILocation[]> {
    const rawLocationRanges =
        await this.debuggerWorkspaceBinding.uiLocationRangeToRawLocationRanges(uiSourceCode, textRange);
    const breakLocationLists = await Promise.all(rawLocationRanges.map(
        ({start, end}) => start.debuggerModel.getPossibleBreakpoints(start, end, /* restrictToFunction */ false)));
    const breakLocations = breakLocationLists.flat();

    const uiLocations = new Map<string, Workspace.UISourceCode.UILocation>();
    await Promise.all(breakLocations.map(async breakLocation => {
      const uiLocation = await this.debuggerWorkspaceBinding.rawLocationToUILocation(breakLocation);
      if (uiLocation === null) {
        return;
      }

      // The "canonical" UI locations don't need to be in our `uiSourceCode`.
      if (uiLocation.uiSourceCode !== uiSourceCode) {
        return;
      }

      // Since we ask for all overlapping ranges above, we might also get breakable locations
      // outside of the `textRange`.
      if (!textRange.containsLocation(uiLocation.lineNumber, uiLocation.columnNumber ?? 0)) {
        return;
      }

      uiLocations.set(uiLocation.id(), uiLocation);
    }));
    return [...uiLocations.values()];
  }

  breakpointLocationsForUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): BreakpointLocation[] {
    const breakpoints = this.#breakpointsForUISourceCode.get(uiSourceCode);
    return breakpoints ? Array.from(breakpoints.values()) : [];
  }

  #getAllBreakpointsForUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): Breakpoint[] {
    const uiBreakpoints = this.breakpointLocationsForUISourceCode(uiSourceCode).map(b => b.breakpoint);
    return uiBreakpoints.concat(Array.from(this.#breakpointsForHomeUISourceCode.get(uiSourceCode) ?? []));
  }

  allBreakpointLocations(): BreakpointLocation[] {
    const result = [];
    for (const breakpoints of this.#breakpointsForUISourceCode.values()) {
      result.push(...breakpoints.values());
    }
    return result;
  }

  removeBreakpoint(breakpoint: Breakpoint, removeFromStorage: boolean): void {
    const storageId = breakpoint.breakpointStorageId();
    if (removeFromStorage) {
      this.storage.removeBreakpoint(storageId);
    }
    this.#breakpointByStorageId.delete(storageId);
  }

  uiLocationAdded(breakpoint: Breakpoint, uiLocation: Workspace.UISourceCode.UILocation): void {
    let breakpoints = this.#breakpointsForUISourceCode.get(uiLocation.uiSourceCode);
    if (!breakpoints) {
      breakpoints = new Map();
      this.#breakpointsForUISourceCode.set(uiLocation.uiSourceCode, breakpoints);
    }
    const breakpointLocation = new BreakpointLocation(breakpoint, uiLocation);
    breakpoints.set(uiLocation.id(), breakpointLocation);
    this.dispatchEventToListeners(Events.BreakpointAdded, breakpointLocation);
  }

  uiLocationRemoved(uiLocation: Workspace.UISourceCode.UILocation): void {
    const breakpoints = this.#breakpointsForUISourceCode.get(uiLocation.uiSourceCode);
    if (!breakpoints) {
      return;
    }
    const breakpointLocation = breakpoints.get(uiLocation.id()) || null;
    if (!breakpointLocation) {
      return;
    }
    breakpoints.delete(uiLocation.id());
    if (breakpoints.size === 0) {
      this.#breakpointsForUISourceCode.delete(uiLocation.uiSourceCode);
    }
    this.dispatchEventToListeners(Events.BreakpointRemoved, breakpointLocation);
  }

  supportsConditionalBreakpoints(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
    return this.debuggerWorkspaceBinding.supportsConditionalBreakpoints(uiSourceCode);
  }
}

export enum Events {
  /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
  BreakpointAdded = 'breakpoint-added',
  BreakpointRemoved = 'breakpoint-removed',
  /* eslint-enable @typescript-eslint/naming-convention */
}

export interface EventTypes {
  [Events.BreakpointAdded]: BreakpointLocation;
  [Events.BreakpointRemoved]: BreakpointLocation;
}

export const enum DebuggerUpdateResult {
  OK = 'OK',
  ERROR_BREAKPOINT_CLASH = 'ERROR_BREAKPOINT_CLASH',
  ERROR_BACKEND = 'ERROR_BACKEND',

  // PENDING implies that the current update requires another re-run.
  PENDING = 'PENDING',
}

export type ScheduleUpdateResult =
    DebuggerUpdateResult.OK|DebuggerUpdateResult.ERROR_BACKEND|DebuggerUpdateResult.ERROR_BREAKPOINT_CLASH;

const enum ResolveLocationResult {
  OK = 'OK',
  ERROR = 'ERROR',
}

export class Breakpoint implements SDK.TargetManager.SDKModelObserver<SDK.DebuggerModel.DebuggerModel> {
  readonly breakpointManager: BreakpointManager;
  /** Bound locations */
  readonly #uiLocations = new Set<Workspace.UISourceCode.UILocation>();
  /** All known UISourceCodes with this url. This also includes UISourceCodes for the inline scripts embedded in a resource with this URL. */
  readonly uiSourceCodes = new Set<Workspace.UISourceCode.UISourceCode>();
  #storageState!: BreakpointStorageState;
  #origin: BreakpointOrigin;
  isRemoved = false;
  /**
   * Fallback positions in case a target doesn't have a script where this breakpoint would fit.
   * The `ModelBreakpoint` sends this optimistically to a target in case a matching script is
   * loaded later.
   *
   * Since every `ModelBreakpoint` can read/write this variable, it's slightly arbitrary. In
   * general `lastResolvedState` contains the state of the last `ModelBreakpoint` that attempted
   * to update the breakpoint(s) in the backend.
   *
   * The state gets populated from the storage if/when we set all breakpoints eagerly
   * on debugger startup so that the backend sets the breakpoints as soon as possible
   * (crbug.com/1442232, under a flag).
   */
  #lastResolvedState: Breakpoint.State|null = null;
  readonly #modelBreakpoints = new Map<SDK.DebuggerModel.DebuggerModel, ModelBreakpoint>();

  constructor(
      breakpointManager: BreakpointManager, primaryUISourceCode: Workspace.UISourceCode.UISourceCode|null,
      storageState: BreakpointStorageState, origin: BreakpointOrigin) {
    this.breakpointManager = breakpointManager;
    this.#origin = origin;

    this.updateState(storageState);
    if (primaryUISourceCode) {
      // User is setting the breakpoint in an existing source.
      console.assert(primaryUISourceCode.contentType().name() === storageState.resourceTypeName);
      this.addUISourceCode(primaryUISourceCode);
    } else {
      // We are setting the breakpoint from storage.
      this.#setLastResolvedStateFromStorage(storageState);
    }

    this.breakpointManager.targetManager.observeModels(SDK.DebuggerModel.DebuggerModel, this);
  }

  #setLastResolvedStateFromStorage(storageState: BreakpointStorageState): void {
    if (storageState.resolvedState) {
      this.#lastResolvedState = storageState.resolvedState.map(s => ({...s, scriptHash: ''}));
    } else if (storageState.resourceTypeName === Common.ResourceType.resourceTypes.Script.name()) {
      // If we are setting the breakpoint from storage (i.e., primaryUISourceCode is null),
      // and the location is not source mapped, then set the last known state to
      // the state from storage so that the breakpoints are pre-set into the backend eagerly.
      this.#lastResolvedState = [{
        url: storageState.url,
        lineNumber: storageState.lineNumber,
        columnNumber: storageState.columnNumber,
        scriptHash: '',
        condition: this.backendCondition(),
      }];
    }
  }

  getLastResolvedState(): Breakpoint.State|null {
    return this.#lastResolvedState;
  }

  updateLastResolvedState(locations: Position[]|null): void {
    this.#lastResolvedState = locations;

    let locationsOrUndefined: ScriptBreakpointLocation[]|undefined = undefined;
    if (locations) {
      locationsOrUndefined = locations.map(
          p => ({url: p.url, lineNumber: p.lineNumber, columnNumber: p.columnNumber, condition: p.condition}));
    }

    if (resolvedStateEqual(this.#storageState.resolvedState, locationsOrUndefined)) {
      return;
    }
    this.#storageState = {...this.#storageState, resolvedState: locationsOrUndefined};
    this.breakpointManager.storage.updateBreakpoint(this.#storageState);
  }

  get origin(): BreakpointOrigin {
    return this.#origin;
  }

  async refreshInDebugger(): Promise<void> {
    if (!this.isRemoved) {
      const modelBreakpoints = Array.from(this.#modelBreakpoints.values());
      await Promise.all(modelBreakpoints.map(async modelBreakpoint => {
        await modelBreakpoint.resetBreakpoint();
        return await this.#updateModel(modelBreakpoint);
      }));
    }
  }

  modelAdded(debuggerModel: SDK.DebuggerModel.DebuggerModel): void {
    const debuggerWorkspaceBinding = this.breakpointManager.debuggerWorkspaceBinding;
    const modelBreakpoint = new ModelBreakpoint(debuggerModel, this, debuggerWorkspaceBinding);
    this.#modelBreakpoints.set(debuggerModel, modelBreakpoint);
    void this.#updateModel(modelBreakpoint);

    debuggerModel.addEventListener(SDK.DebuggerModel.Events.DebuggerWasEnabled, this.#onDebuggerEnabled, this);
    debuggerModel.addEventListener(SDK.DebuggerModel.Events.DebuggerWasDisabled, this.#onDebuggerDisabled, this);
    debuggerModel.addEventListener(SDK.DebuggerModel.Events.ScriptSourceWasEdited, this.#onScriptWasEdited, this);
  }

  modelRemoved(debuggerModel: SDK.DebuggerModel.DebuggerModel): void {
    const modelBreakpoint = this.#modelBreakpoints.get(debuggerModel);
    modelBreakpoint?.cleanUpAfterDebuggerIsGone();
    this.#modelBreakpoints.delete(debuggerModel);

    this.#removeDebuggerModelListeners(debuggerModel);
  }

  #removeDebuggerModelListeners(debuggerModel: SDK.DebuggerModel.DebuggerModel): void {
    debuggerModel.removeEventListener(SDK.DebuggerModel.Events.DebuggerWasEnabled, this.#onDebuggerEnabled, this);
    debuggerModel.removeEventListener(SDK.DebuggerModel.Events.DebuggerWasDisabled, this.#onDebuggerDisabled, this);
    debuggerModel.removeEventListener(SDK.DebuggerModel.Events.ScriptSourceWasEdited, this.#onScriptWasEdited, this);
  }

  #onDebuggerEnabled(event: Common.EventTarget.EventTargetEvent<SDK.DebuggerModel.DebuggerModel>): void {
    const debuggerModel = event.data;
    const model = this.#modelBreakpoints.get(debuggerModel);
    if (model) {
      void this.#updateModel(model);
    }
  }

  #onDebuggerDisabled(event: Common.EventTarget.EventTargetEvent<SDK.DebuggerModel.DebuggerModel>): void {
    const debuggerModel = event.data;
    const model = this.#modelBreakpoints.get(debuggerModel);
    model?.cleanUpAfterDebuggerIsGone();
  }

  async #onScriptWasEdited(
      event: Common.EventTarget
          .EventTargetEvent<{script: SDK.Script.Script, status: Protocol.Debugger.SetScriptSourceResponseStatus}>):
      Promise<void> {
    const {source: debuggerModel, data: {script, status}} = event;
    if (status !== Protocol.Debugger.SetScriptSourceResponseStatus.Ok) {
      return;
    }

    // V8 throws away breakpoints on all functions in a live edited script. Here we attempt to re-set them again at the
    // same position. This is because we don't know what was edited and how the breakpoint should move, e.g. if the file
    // was originally changed on the filesystem (via workspace).
    // If the live edit originated in DevTools (in CodeMirror), then the `DebuggerPlugin` will remove the breakpoint
    // wholesale and re-apply based on the diff.

    console.assert(debuggerModel instanceof SDK.DebuggerModel.DebuggerModel);
    const model = this.#modelBreakpoints.get(debuggerModel as SDK.DebuggerModel.DebuggerModel);
    if (model?.wasSetIn(script.scriptId)) {
      await model.resetBreakpoint();
      void this.#updateModel(model);
    }
  }

  modelBreakpoint(debuggerModel: SDK.DebuggerModel.DebuggerModel): ModelBreakpoint|undefined {
    return this.#modelBreakpoints.get(debuggerModel);
  }

  addUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
    if (!this.uiSourceCodes.has(uiSourceCode)) {
      this.uiSourceCodes.add(uiSourceCode);
      this.breakpointManager.addHomeUISourceCode(uiSourceCode, this);
      if (!this.bound()) {
        this.breakpointManager.uiLocationAdded(this, this.defaultUILocation(uiSourceCode));
      }
    }
  }

  clearUISourceCodes(): void {
    if (!this.bound()) {
      this.removeAllUnboundLocations();
    }
    for (const uiSourceCode of this.uiSourceCodes) {
      this.removeUISourceCode(uiSourceCode);
    }
  }

  removeUISourceCode(uiSourceCode: Workspace.UISourceCode.UISourceCode): void {
    if (this.uiSourceCodes.has(uiSourceCode)) {
      this.uiSourceCodes.delete(uiSourceCode);
      this.breakpointManager.removeHomeUISourceCode(uiSourceCode, this);
      if (!this.bound()) {
        this.breakpointManager.uiLocationRemoved(this.defaultUILocation(uiSourceCode));
      }
    }

    // Do we need to do this? Not sure if bound locations will leak...
    if (this.bound()) {
      for (const uiLocation of this.#uiLocations) {
        if (uiLocation.uiSourceCode === uiSourceCode) {
          this.#uiLocations.delete(uiLocation);
          this.breakpointManager.uiLocationRemoved(uiLocation);
        }
      }

      if (!this.bound() && !this.isRemoved) {
        // Switch to unbound locations
        this.addAllUnboundLocations();
      }
    }
  }

  url(): Platform.DevToolsPath.UrlString {
    return this.#storageState.url;
  }

  lineNumber(): number {
    return this.#storageState.lineNumber;
  }

  columnNumber(): number|undefined {
    return this.#storageState.columnNumber;
  }

  uiLocationAdded(uiLocation: Workspace.UISourceCode.UILocation): void {
    if (this.isRemoved) {
      return;
    }
    if (!this.bound()) {
      // This is our first bound location; remove all unbound locations
      this.removeAllUnboundLocations();
    }
    this.#uiLocations.add(uiLocation);
    this.breakpointManager.uiLocationAdded(this, uiLocation);
  }

  uiLocationRemoved(uiLocation: Workspace.UISourceCode.UILocation): void {
    if (this.#uiLocations.has(uiLocation)) {
      this.#uiLocations.delete(uiLocation);
      this.breakpointManager.uiLocationRemoved(uiLocation);
      if (!this.bound() && !this.isRemoved) {
        this.addAllUnboundLocations();
      }
    }
  }

  enabled(): boolean {
    return this.#storageState.enabled;
  }

  bound(): boolean {
    return this.#uiLocations.size !== 0;
  }

  setEnabled(enabled: boolean): void {
    this.updateState({...this.#storageState, enabled});
  }

  /**
   * The breakpoint condition as entered by the user.
   */
  condition(): UserCondition {
    return this.#storageState.condition;
  }

  /**
   * The breakpoint condition as it is sent to V8.
   */
  backendCondition(): SDK.DebuggerModel.BackendCondition;
  backendCondition(location: SDK.DebuggerModel.Location): Promise<SDK.DebuggerModel.BackendCondition>;
  backendCondition(location?: SDK.DebuggerModel.Location): SDK.DebuggerModel.BackendCondition
      |Promise<SDK.DebuggerModel.BackendCondition> {
    const condition: string = this.condition();
    if (condition === '') {
      return '' as SDK.DebuggerModel.BackendCondition;
    }

    const addSourceUrl = (condition: string): SDK.DebuggerModel.BackendCondition => {
      let sourceUrl = SDK.DebuggerModel.COND_BREAKPOINT_SOURCE_URL;
      if (this.isLogpoint()) {
        condition = `${LOGPOINT_PREFIX}${condition}${LOGPOINT_SUFFIX}`;
        sourceUrl = SDK.DebuggerModel.LOGPOINT_SOURCE_URL;
      }
      return `${condition}\n\n//# sourceURL=${sourceUrl}` as SDK.DebuggerModel.BackendCondition;
    };

    if (location) {
      return SourceMapScopes.NamesResolver.allVariablesAtPosition(location)
          .then(nameMap => Formatter.FormatterWorkerPool.formatterWorkerPool().javaScriptSubstitute(condition, nameMap))
          .catch(() => condition)
          .then(subsitutedCondition => addSourceUrl(subsitutedCondition), () => addSourceUrl(condition));
    }
    return addSourceUrl(condition);
  }

  setCondition(condition: UserCondition, isLogpoint: boolean): void {
    this.updateState({...this.#storageState, condition, isLogpoint});
  }

  isLogpoint(): boolean {
    return this.#storageState.isLogpoint;
  }

  get storageState(): BreakpointStorageState {
    return this.#storageState;
  }

  updateState(newState: BreakpointStorageState): void {
    // Only 'enabled', 'condition' and 'isLogpoint' can change (except during initialization).
    if (this.#storageState &&
        (this.#storageState.url !== newState.url || this.#storageState.lineNumber !== newState.lineNumber ||
         this.#storageState.columnNumber !== newState.columnNumber)) {
      throw new Error('Invalid breakpoint state update');
    }
    if (this.#storageState?.enabled === newState.enabled && this.#storageState?.condition === newState.condition &&
        this.#storageState?.isLogpoint === newState.isLogpoint) {
      return;
    }
    this.#storageState = newState;
    this.breakpointManager.storage.updateBreakpoint(this.#storageState);
    void this.updateBreakpoint();
  }

  async updateBreakpoint(): Promise<void> {
    if (!this.bound()) {
      this.removeAllUnboundLocations();
      if (!this.isRemoved) {
        this.addAllUnboundLocations();
      }
    }
    return await this.#updateModels();
  }

  async remove(keepInStorage: boolean): Promise<void> {
    if (this.getIsRemoved()) {
      return;
    }
    this.isRemoved = true;
    const removeFromStorage = !keepInStorage;

    for (const debuggerModel of this.#modelBreakpoints.keys()) {
      this.#removeDebuggerModelListeners(debuggerModel);
    }
    await this.#updateModels();

    this.breakpointManager.removeBreakpoint(this, removeFromStorage);
    this.breakpointManager.targetManager.unobserveModels(SDK.DebuggerModel.DebuggerModel, this);
    this.clearUISourceCodes();
  }

  breakpointStorageId(): string {
    return Storage.computeId(this.#storageState);
  }

  private defaultUILocation(uiSourceCode: Workspace.UISourceCode.UISourceCode): Workspace.UISourceCode.UILocation {
    return BreakpointManager.uiLocationFromBreakpointLocation(
        uiSourceCode, this.#storageState.lineNumber, this.#storageState.columnNumber);
  }

  private removeAllUnboundLocations(): void {
    for (const uiSourceCode of this.uiSourceCodes) {
      this.breakpointManager.uiLocationRemoved(this.defaultUILocation(uiSourceCode));
    }
  }

  private addAllUnboundLocations(): void {
    for (const uiSourceCode of this.uiSourceCodes) {
      this.breakpointManager.uiLocationAdded(this, this.defaultUILocation(uiSourceCode));
    }
  }

  getUiSourceCodes(): Set<Workspace.UISourceCode.UISourceCode> {
    return this.uiSourceCodes;
  }

  getIsRemoved(): boolean {
    return this.isRemoved;
  }

  async #updateModels(): Promise<void> {
    await Promise.all(Array.from(this.#modelBreakpoints.values()).map(model => this.#updateModel(model)));
  }

  async #updateModel(model: ModelBreakpoint): Promise<void> {
    const result = await model.scheduleUpdateInDebugger();
    if (result === DebuggerUpdateResult.ERROR_BACKEND) {
      await this.remove(true /* keepInStorage */);
    } else if (result === DebuggerUpdateResult.ERROR_BREAKPOINT_CLASH) {
      await this.remove(false /* keepInStorage */);
    }
  }
}

/**
 * Represents a single `Breakpoint` for a specific target.
 *
 * The `BreakpointManager` unconditionally creates a `ModelBreakpoint` instance
 * for each target since any target could load a matching script after the fact.
 *
 * Each `ModelBreakpoint` can represent multiple actual breakpoints in V8. E.g.
 * inlining in WASM or multiple bundles containing the same utility function.
 *
 * This means each `Modelbreakpoint` represents 0 to n actual breakpoints in
 * for it's specific target.
 */
export class ModelBreakpoint {
  #debuggerModel: SDK.DebuggerModel.DebuggerModel;
  #breakpoint: Breakpoint;
  readonly #debuggerWorkspaceBinding: Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding;
  readonly #liveLocations = new Bindings.LiveLocation.LiveLocationPool();
  readonly #uiLocations = new Map<Bindings.LiveLocation.LiveLocation, Workspace.UISourceCode.UILocation>();
  #updateMutex = new Common.Mutex.Mutex();
  #cancelCallback = false;
  #currentState: Breakpoint.State|null = null;
  #breakpointIds: Protocol.Debugger.BreakpointId[] = [];
  /**
   * We track all the script IDs this ModelBreakpoint was actually set in. This allows us
   * to properly reset this ModelBreakpoint after a script was live edited.
   */
  #resolvedScriptIds = new Set<Protocol.Runtime.ScriptId>();

  constructor(
      debuggerModel: SDK.DebuggerModel.DebuggerModel, breakpoint: Breakpoint,
      debuggerWorkspaceBinding: Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding) {
    this.#debuggerModel = debuggerModel;
    this.#breakpoint = breakpoint;
    this.#debuggerWorkspaceBinding = debuggerWorkspaceBinding;
  }

  get currentState(): Breakpoint.State|null {
    return this.#currentState;
  }

  resetLocations(): void {
    for (const uiLocation of this.#uiLocations.values()) {
      this.#breakpoint.uiLocationRemoved(uiLocation);
    }

    this.#uiLocations.clear();
    this.#liveLocations.disposeAll();
    this.#resolvedScriptIds.clear();
  }

  async scheduleUpdateInDebugger(): Promise<ScheduleUpdateResult> {
    if (!this.#debuggerModel.debuggerEnabled()) {
      return DebuggerUpdateResult.OK;
    }

    const release = await this.#updateMutex.acquire();
    let result = DebuggerUpdateResult.PENDING;
    while (result === DebuggerUpdateResult.PENDING) {
      result = await this.#updateInDebugger();

      // TODO(crbug.com/1229541): This is a mirror to the quickfix
      // in #updateInDebugger. If the model didn't enable yet, instead of
      // spamming the "setBreakpoint" call to the backend, we'll wait for
      // it to finish enabling.
      if (this.#debuggerModel.debuggerEnabled() && !this.#debuggerModel.isReadyToPause()) {
        await this.#debuggerModel.once(SDK.DebuggerModel.Events.DebuggerIsReadyToPause);
        if (!this.#debuggerModel.debuggerEnabled()) {
          // If the model failed to enable, we won't try to set the breakpoint.
          result = DebuggerUpdateResult.OK;
          break;
        }
      }
    }
    release();
    return result;
  }

  private scriptDiverged(): boolean {
    for (const uiSourceCode of this.#breakpoint.getUiSourceCodes()) {
      const scriptFile = this.#debuggerWorkspaceBinding.scriptFile(uiSourceCode, this.#debuggerModel);
      if (scriptFile?.hasDivergedFromVM()) {
        return true;
      }
    }
    return false;
  }

  async #updateInDebugger(): Promise<DebuggerUpdateResult> {
    if (this.#debuggerModel.target().isDisposed()) {
      this.cleanUpAfterDebuggerIsGone();
      return DebuggerUpdateResult.OK;
    }
    const lineNumber = this.#breakpoint.lineNumber();
    const columnNumber = this.#breakpoint.columnNumber();
    const condition = this.#breakpoint.backendCondition();

    // Calculate the new state.
    let newState: Breakpoint.State|null = null;
    if (!this.#breakpoint.getIsRemoved() && this.#breakpoint.enabled() && !this.scriptDiverged()) {
      let debuggerLocations: SDK.DebuggerModel.Location[] = [];
      for (const uiSourceCode of this.#breakpoint.getUiSourceCodes()) {
        const {lineNumber: uiLineNumber, columnNumber: uiColumnNumber} =
            BreakpointManager.uiLocationFromBreakpointLocation(uiSourceCode, lineNumber, columnNumber);
        const locations =
            await this.#debuggerWorkspaceBinding.uiLocationToRawLocations(uiSourceCode, uiLineNumber, uiColumnNumber);
        debuggerLocations = locations.filter(location => location.debuggerModel === this.#debuggerModel);
        if (debuggerLocations.length) {
          break;
        }
      }
      if (debuggerLocations.length && debuggerLocations.every(loc => loc.script())) {
        const positions = await Promise.all(debuggerLocations.map(async loc => {
          const script = loc.script() as SDK.Script.Script;
          const condition = await this.#breakpoint.backendCondition(loc);
          return {
            url: script.sourceURL,
            scriptHash: script.hash,
            lineNumber: loc.lineNumber,
            columnNumber: loc.columnNumber,
            condition,
          };
        }));
        newState = positions.slice(0);  // Create a copy
      } else if (!Root.Runtime.experiments.isEnabled(Root.ExperimentNames.ExperimentName.INSTRUMENTATION_BREAKPOINTS)) {
        // Use this fallback if we do not have instrumentation breakpoints enabled yet. This currently makes
        // sure that v8 knows about the breakpoint and is able to restore it whenever the script is parsed.
        const lastResolvedState = this.#breakpoint.getLastResolvedState();
        if (lastResolvedState) {
          // Re-use position information from fallback but use up-to-date condition.
          newState = lastResolvedState.map(position => ({...position, condition}));
        } else {
          // TODO(bmeurer): This fallback doesn't make a whole lot of sense, we should
          // at least signal a warning to the developer that this #breakpoint wasn't
          // really resolved.
          const position = {
            url: this.#breakpoint.url(),
            scriptHash: '',
            lineNumber,
            columnNumber,
            condition,
          };
          newState = [position];
        }
      }
    }
    const hasBackendState = this.#breakpointIds.length;

    // Case 1: Back-end has some breakpoints and the new state is a proper subset
    // of the back-end state (in particular the new state contains at least a single
    // position, meaning we're not removing the breakpoint completely).
    if (hasBackendState && Breakpoint.State.subset(newState, this.#currentState)) {
      return DebuggerUpdateResult.OK;
    }

    this.#breakpoint.updateLastResolvedState(newState);

    // Case 2: State has changed, and the back-end has outdated information on old
    // breakpoints.
    if (hasBackendState) {
      // Reset the current state.
      await this.resetBreakpoint();
      // Schedule another run of updates, to finally update to the new state.
      return DebuggerUpdateResult.PENDING;
    }

    // Case 3: State is null (no breakpoints to set), and back-end is up to date
    // (no info on breakpoints).
    if (!newState) {
      return DebuggerUpdateResult.OK;
    }

    // Case 4: State is not null, so we have breakpoints to set and the back-end
    // has no information on breakpoints yet. Set the breakpoints.
    const {breakpointIds, locations, serverError} = await this.#setBreakpointOnBackend(newState);

    const maybeRescheduleUpdate =
        serverError && this.#debuggerModel.debuggerEnabled() && !this.#debuggerModel.isReadyToPause();
    if (!breakpointIds.length && maybeRescheduleUpdate) {
      // TODO(crbug.com/1229541): This is a quickfix to prevent #breakpoints from
      // disappearing if the Debugger is actually not enabled
      // yet. This quickfix should be removed as soon as we have a solution
      // to correctly synchronize the front-end with the inspector back-end.
      return DebuggerUpdateResult.PENDING;
    }

    this.#currentState = newState;
    if (this.#cancelCallback) {
      this.#cancelCallback = false;
      return DebuggerUpdateResult.OK;
    }

    // Something went wrong: we expect to have a non-null state, but have not received any
    // breakpointIds from the back-end.
    if (!breakpointIds.length) {
      return DebuggerUpdateResult.ERROR_BACKEND;
    }

    this.#breakpointIds = breakpointIds;
    this.#breakpointIds.forEach(
        breakpointId => this.#debuggerModel.addBreakpointListener(breakpointId, this.breakpointResolved, this));
    const resolvedResults = await Promise.all(locations.map(location => this.addResolvedLocation(location)));

    // Breakpoint clash: the resolved location resolves to a different breakpoint, report an error.
    if (resolvedResults.includes(ResolveLocationResult.ERROR)) {
      return DebuggerUpdateResult.ERROR_BREAKPOINT_CLASH;
    }
    return DebuggerUpdateResult.OK;
  }

  async #setBreakpointOnBackend(positions: Breakpoint.State): Promise<{
    breakpointIds: Protocol.Debugger.BreakpointId[],
    locations: SDK.DebuggerModel.Location[],
    serverError: boolean,
  }> {
    const results = await Promise.all(positions.map(pos => {
      if (pos.url) {
        return this.#debuggerModel.setBreakpointByURL(pos.url, pos.lineNumber, pos.columnNumber, pos.condition);
      }
      return this.#debuggerModel.setBreakpointInAnonymousScript(
          pos.scriptHash, pos.lineNumber, pos.columnNumber, pos.condition);
    }));
    const breakpointIds: Protocol.Debugger.BreakpointId[] = [];
    let locations: SDK.DebuggerModel.Location[] = [];
    let serverError = false;
    for (const result of results) {
      if (result.breakpointId) {
        breakpointIds.push(result.breakpointId);
        locations = locations.concat(result.locations);
      } else {
        serverError = true;
      }
    }
    return {breakpointIds, locations, serverError};
  }

  async resetBreakpoint(): Promise<void> {
    if (!this.#breakpointIds.length) {
      return;
    }
    this.resetLocations();
    await Promise.all(this.#breakpointIds.map(id => this.#debuggerModel.removeBreakpoint(id)));
    this.didRemoveFromDebugger();
    this.#currentState = null;
  }

  private didRemoveFromDebugger(): void {
    if (this.#cancelCallback) {
      this.#cancelCallback = false;
      return;
    }

    this.resetLocations();
    this.#breakpointIds.forEach(
        breakpointId => this.#debuggerModel.removeBreakpointListener(breakpointId, this.breakpointResolved, this));
    this.#breakpointIds = [];
  }

  private async breakpointResolved({data: location}: Common.EventTarget.EventTargetEvent<SDK.DebuggerModel.Location>):
      Promise<void> {
    const result = await this.addResolvedLocation(location);
    if (result === ResolveLocationResult.ERROR) {
      await this.#breakpoint.remove(false /* keepInStorage */);
    }
  }

  private async locationUpdated(liveLocation: Bindings.LiveLocation.LiveLocation): Promise<void> {
    const oldUILocation = this.#uiLocations.get(liveLocation);
    const uiLocation = await liveLocation.uiLocation();

    if (oldUILocation) {
      this.#breakpoint.uiLocationRemoved(oldUILocation);
    }

    if (uiLocation) {
      this.#uiLocations.set(liveLocation, uiLocation);
      this.#breakpoint.uiLocationAdded(uiLocation);
    } else {
      this.#uiLocations.delete(liveLocation);
    }
  }

  private async addResolvedLocation(location: SDK.DebuggerModel.Location): Promise<ResolveLocationResult> {
    this.#resolvedScriptIds.add(location.scriptId);
    const uiLocation = await this.#debuggerWorkspaceBinding.rawLocationToUILocation(location);
    if (!uiLocation) {
      return ResolveLocationResult.OK;
    }
    const breakpointLocation = this.#breakpoint.breakpointManager.findBreakpoint(uiLocation);
    if (breakpointLocation && breakpointLocation.breakpoint !== this.#breakpoint) {
      // location clash
      return ResolveLocationResult.ERROR;
    }
    await this.#debuggerWorkspaceBinding.createLiveLocation(
        location, this.locationUpdated.bind(this), this.#liveLocations);
    return ResolveLocationResult.OK;
  }

  cleanUpAfterDebuggerIsGone(): void {
    this.#cancelCallback = true;
    this.resetLocations();
    this.#currentState = null;
    if (this.#breakpointIds.length) {
      this.didRemoveFromDebugger();
    }
  }

  /** @returns true, iff this `ModelBreakpoint` was set (at some point) in `scriptId` */
  wasSetIn(scriptId: Protocol.Runtime.ScriptId): boolean {
    return this.#resolvedScriptIds.has(scriptId);
  }
}

/**
 * A concrete breakpoint position in a specific target. Each `ModelBreakpoint`
 * consists of multiple of these.
 *
 * Note that a `Position` only denotes where we *want* to set a breakpoint, not
 * where it was actually set by V8 after the fact.
 */
interface Position {
  url: Platform.DevToolsPath.UrlString;
  scriptHash: string;
  lineNumber: number;
  columnNumber?: number;
  condition: SDK.DebuggerModel.BackendCondition;
}

export const enum BreakpointOrigin {
  USER_ACTION = 'USER_ACTION',
  OTHER = 'RESTORED',
}

export namespace Breakpoint {

  export type State = Position[];
  export namespace State {
    export function subset(stateA?: State|null, stateB?: State|null): boolean {
      if (stateA === stateB) {
        return true;
      }
      if (!stateA || !stateB) {
        return false;
      }
      if (stateA.length === 0) {
        return false;
      }
      for (const positionA of stateA) {
        if (stateB.find(
                positionB => positionA.url === positionB.url && positionA.scriptHash === positionB.scriptHash &&
                    positionA.lineNumber === positionB.lineNumber &&
                    positionA.columnNumber === positionB.columnNumber &&
                    positionA.condition === positionB.condition) === undefined) {
          return false;
        }
      }
      return true;
    }
  }
}

class Storage {
  readonly setting: Common.Settings.Setting<BreakpointStorageState[]>;
  readonly breakpoints: Map<string, BreakpointStorageState>;
  #muted: boolean;

  constructor(settings: Common.Settings.Settings) {
    this.setting = settings.createLocalSetting('breakpoints', []);
    this.breakpoints = new Map();
    this.#muted = false;
    for (const breakpoint of this.setting.get()) {
      this.breakpoints.set(Storage.computeId(breakpoint), breakpoint);
    }
  }

  mute(): void {
    this.#muted = true;
  }

  unmute(): void {
    this.#muted = false;
  }

  breakpointItems(url: Platform.DevToolsPath.UrlString, resourceTypeName?: string): BreakpointStorageState[] {
    const breakpoints = [];
    for (const breakpoint of this.breakpoints.values()) {
      if (breakpoint.url !== url) {
        continue;
      }
      if (breakpoint.resourceTypeName !== resourceTypeName && resourceTypeName !== undefined) {
        continue;
      }
      breakpoints.push(breakpoint);
    }
    return breakpoints;
  }

  updateBreakpoint(storageState: BreakpointStorageState): void {
    if (this.#muted) {
      return;
    }
    const storageId = Storage.computeId(storageState);
    if (!storageId) {
      return;
    }
    // Delete the breakpoint and re-insert it so that it is moved to the last position in the iteration order.
    this.breakpoints.delete(storageId);
    this.breakpoints.set(storageId, storageState);
    this.save();
  }

  removeBreakpoint(storageId: string): void {
    if (this.#muted) {
      return;
    }
    this.breakpoints.delete(storageId);
    this.save();
  }

  private save(): void {
    this.setting.set(Array.from(this.breakpoints.values()));
  }

  static computeId({url, resourceTypeName, lineNumber, columnNumber}: BreakpointStorageState): string {
    if (!url) {
      return '';
    }
    let id = `${url}:${resourceTypeName}:${lineNumber}`;
    if (columnNumber !== undefined) {
      id += `:${columnNumber}`;
    }
    return id;
  }
}

function resolvedStateEqual(
    lhs: ScriptBreakpointLocation[]|undefined, rhs: ScriptBreakpointLocation[]|undefined): boolean {
  if (lhs === rhs) {
    return true;
  }
  if (!lhs || !rhs || lhs.length !== rhs.length) {
    return false;
  }
  for (let i = 0; i < lhs.length; i++) {
    const lhsLoc = lhs[i];
    const rhsLoc = rhs[i];
    if (lhsLoc.url !== rhsLoc.url || lhsLoc.lineNumber !== rhsLoc.lineNumber ||
        lhsLoc.columnNumber !== rhsLoc.columnNumber || lhsLoc.condition !== rhsLoc.condition) {
      return false;
    }
  }
  return true;
}

/**
 * A breakpoint condition as entered by the user. We use the type to
 * distinguish from {@link SDK.DebuggerModel.BackendCondition}.
 */
export type UserCondition = Platform.Brand.Brand<string, 'UserCondition'>;
export const EMPTY_BREAKPOINT_CONDITION = '' as UserCondition;
export const NEVER_PAUSE_HERE_CONDITION = 'false' as UserCondition;

export interface ScriptBreakpointLocation {
  readonly url: Platform.DevToolsPath.UrlString;
  readonly lineNumber: number;
  readonly columnNumber?: number;
  readonly condition: SDK.DebuggerModel.BackendCondition;
}

/**
 * All the data for a single `Breakpoint` thats stored in the settings.
 * Whenever any of these change, we need to update the settings.
 */
export interface BreakpointStorageState {
  readonly url: Platform.DevToolsPath.UrlString;
  readonly resourceTypeName: string;
  readonly lineNumber: number;
  readonly columnNumber?: number;
  readonly condition: UserCondition;
  readonly enabled: boolean;
  readonly isLogpoint: boolean;
  readonly resolvedState?: ScriptBreakpointLocation[];
}

export class BreakpointLocation {
  readonly breakpoint: Breakpoint;
  readonly uiLocation: Workspace.UISourceCode.UILocation;

  constructor(breakpoint: Breakpoint, uiLocation: Workspace.UISourceCode.UILocation) {
    this.breakpoint = breakpoint;
    this.uiLocation = uiLocation;
  }
}

const LOGPOINT_PREFIX = '/** DEVTOOLS_LOGPOINT */ console.log(';
const LOGPOINT_SUFFIX = ')';
