// 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 Protocol from '../../generated/protocol.js';
import * as Common from '../common/common.js';
import * as Host from '../host/host.js';
import * as i18n from '../i18n/i18n.js';
import * as Platform from '../platform/platform.js';

import {FrontendMessageType} from './ConsoleModelTypes.js';
import {CPUProfilerModel, type EventData, Events as CPUProfilerModelEvents} from './CPUProfilerModel.js';
import {
  BreakpointType,
  COND_BREAKPOINT_SOURCE_URL,
  Events as DebuggerModelEvents,
  type Location,
  LOGPOINT_SOURCE_URL,
} from './DebuggerModel.js';
import {LogModel} from './LogModel.js';
import {RemoteObject} from './RemoteObject.js';
import {
  Events as ResourceTreeModelEvents,
  type PrimaryPageChangeType,
  type ResourceTreeFrame,
  ResourceTreeModel,
} from './ResourceTreeModel.js';
import {
  type ConsoleAPICall,
  Events as RuntimeModelEvents,
  type ExceptionWithTimestamp,
  type ExecutionContext,
  type QueryObjectRequestedEvent,
  RuntimeModel,
} from './RuntimeModel.js';
import {SDKModel} from './SDKModel.js';
import {Capability, type Target, Type} from './Target.js';
import {TargetManager} from './TargetManager.js';

export {FrontendMessageType} from './ConsoleModelTypes.js';

const UIStrings = {
  /**
   * @description Text shown when the main frame (page) of the website was navigated to a different URL.
   * @example {https://example.com} PH1
   */
  navigatedToS: 'Navigated to {PH1}',
  /**
   * @description Text shown when the main frame (page) of the website was navigated to a different URL
   * and the page was restored from back/forward cache (https://web.dev/bfcache/).
   * @example {https://example.com} PH1
   */
  bfcacheNavigation: 'Navigation to {PH1} was restored from back/forward cache (see https://web.dev/bfcache/)',
  /**
   * @description Text shown in the console when a performance profile (with the given name) was started.
   * @example {title} PH1
   */
  profileSStarted: 'Profile \'\'{PH1}\'\' started.',
  /**
   * @description Text shown in the console when a performance profile (with the given name) was stopped.
   * @example {name} PH1
   */
  profileSFinished: 'Profile \'\'{PH1}\'\' finished.',
  /**
   * @description Error message shown in the console after the user tries to save a JavaScript value to a temporary variable.
   */
  failedToSaveToTempVariable: 'Failed to save to temp variable.',
} as const;

const str_ = i18n.i18n.registerUIStrings('core/sdk/ConsoleModel.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

export class ConsoleModel extends SDKModel<EventTypes> {
  #messages: ConsoleMessage[] = [];
  readonly #messagesByTimestamp = new Platform.MapUtilities.Multimap<number, ConsoleMessage>();
  readonly #messageByExceptionId = new Map<RuntimeModel, Map<number, ConsoleMessage>>();
  #warnings = 0;
  #errors = 0;
  #violations = 0;
  #pageLoadSequenceNumber = 0;
  readonly #targetListeners = new WeakMap<Target, Common.EventTarget.EventDescriptor[]>();

  constructor(target: Target) {
    super(target);

    const resourceTreeModel = target.model(ResourceTreeModel);
    if (!resourceTreeModel || resourceTreeModel.cachedResourcesLoaded()) {
      this.initTarget(target);
      return;
    }

    const eventListener = resourceTreeModel.addEventListener(ResourceTreeModelEvents.CachedResourcesLoaded, () => {
      Common.EventTarget.removeEventListeners([eventListener]);
      this.initTarget(target);
    });
  }

  private initTarget(target: Target): void {
    const eventListeners = [];

    const cpuProfilerModel = target.model(CPUProfilerModel);
    if (cpuProfilerModel) {
      eventListeners.push(cpuProfilerModel.addEventListener(
          CPUProfilerModelEvents.CONSOLE_PROFILE_STARTED, this.consoleProfileStarted.bind(this, cpuProfilerModel)));
      eventListeners.push(cpuProfilerModel.addEventListener(
          CPUProfilerModelEvents.CONSOLE_PROFILE_FINISHED, this.consoleProfileFinished.bind(this, cpuProfilerModel)));
    }

    const resourceTreeModel = target.model(ResourceTreeModel);
    if (resourceTreeModel && target.parentTarget()?.type() !== Type.FRAME) {
      eventListeners.push(resourceTreeModel.addEventListener(
          ResourceTreeModelEvents.PrimaryPageChanged, this.primaryPageChanged, this));
    }

    const runtimeModel = target.model(RuntimeModel);
    if (runtimeModel) {
      eventListeners.push(runtimeModel.addEventListener(
          RuntimeModelEvents.ExceptionThrown, this.exceptionThrown.bind(this, runtimeModel)));
      eventListeners.push(runtimeModel.addEventListener(
          RuntimeModelEvents.ExceptionRevoked, this.exceptionRevoked.bind(this, runtimeModel)));
      eventListeners.push(runtimeModel.addEventListener(
          RuntimeModelEvents.ConsoleAPICalled, this.consoleAPICalled.bind(this, runtimeModel)));
      if (target.parentTarget()?.type() !== Type.FRAME) {
        eventListeners.push(runtimeModel.debuggerModel().addEventListener(
            DebuggerModelEvents.GlobalObjectCleared, this.clearIfNecessary, this));
      }
      eventListeners.push(runtimeModel.addEventListener(
          RuntimeModelEvents.QueryObjectRequested, this.queryObjectRequested.bind(this, runtimeModel)));
    }

    this.#targetListeners.set(target, eventListeners);
  }

  targetRemoved(target: Target): void {
    const runtimeModel = target.model(RuntimeModel);
    if (runtimeModel) {
      this.#messageByExceptionId.delete(runtimeModel);
    }
    Common.EventTarget.removeEventListeners(this.#targetListeners.get(target) || []);
  }

  async evaluateCommandInConsole(
      executionContext: ExecutionContext, originatingMessage: ConsoleMessage, expression: string,
      useCommandLineAPI: boolean): Promise<void> {
    const result = await executionContext.evaluate(
        {
          expression,
          objectGroup: 'console',
          includeCommandLineAPI: useCommandLineAPI,
          silent: false,
          returnByValue: false,
          generatePreview: true,
          replMode: true,
          allowUnsafeEvalBlockedByCSP: false,
        },
        this.target().targetManager().settings.moduleSetting('console-user-activation-eval').get(),
        /* awaitPromise */ false);
    Host.userMetrics.actionTaken(Host.UserMetrics.Action.ConsoleEvaluated);
    if ('error' in result) {
      return;
    }
    await Common.Console.Console.instance().showPromise();
    this.dispatchEventToListeners(
        Events.CommandEvaluated,
        {result: result.object, commandMessage: originatingMessage, exceptionDetails: result.exceptionDetails});
  }

  addCommandMessage(executionContext: ExecutionContext, text: string): ConsoleMessage {
    const commandMessage = new ConsoleMessage(
        executionContext.runtimeModel, Protocol.Log.LogEntrySource.Javascript, null, text,
        {type: FrontendMessageType.Command});
    commandMessage.setExecutionContextId(executionContext.id);
    this.addMessage(commandMessage);
    return commandMessage;
  }

  addMessage(msg: ConsoleMessage): void {
    msg.setPageLoadSequenceNumber(this.#pageLoadSequenceNumber);
    if (msg.source === Common.Console.FrontendMessageSource.ConsoleAPI &&
        msg.type === Protocol.Runtime.ConsoleAPICalledEventType.Clear) {
      this.clearIfNecessary();
    }

    this.#messages.push(msg);
    this.#messagesByTimestamp.set(msg.timestamp, msg);
    const runtimeModel = msg.runtimeModel();
    const exceptionId = msg.getExceptionId();
    if (exceptionId && runtimeModel) {
      let modelMap = this.#messageByExceptionId.get(runtimeModel);
      if (!modelMap) {
        modelMap = new Map();
        this.#messageByExceptionId.set(runtimeModel, modelMap);
      }
      modelMap.set(exceptionId, msg);
    }
    this.incrementErrorWarningCount(msg);
    this.dispatchEventToListeners(Events.MessageAdded, msg);
  }

  private exceptionThrown(
      runtimeModel: RuntimeModel, event: Common.EventTarget.EventTargetEvent<ExceptionWithTimestamp>): void {
    const exceptionWithTimestamp = event.data;
    const affectedResources = extractExceptionMetaData(exceptionWithTimestamp.details.exceptionMetaData);
    const consoleMessage = ConsoleMessage.fromException(
        runtimeModel, exceptionWithTimestamp.details, undefined, exceptionWithTimestamp.timestamp, undefined,
        affectedResources);
    consoleMessage.setExceptionId(exceptionWithTimestamp.details.exceptionId);
    this.addMessage(consoleMessage);
  }

  private exceptionRevoked(runtimeModel: RuntimeModel, event: Common.EventTarget.EventTargetEvent<number>): void {
    const exceptionId = event.data;
    const modelMap = this.#messageByExceptionId.get(runtimeModel);
    const exceptionMessage = modelMap ? modelMap.get(exceptionId) : null;
    if (!exceptionMessage) {
      return;
    }
    this.#errors--;
    exceptionMessage.level = Protocol.Log.LogEntryLevel.Verbose;
    this.dispatchEventToListeners(Events.MessageUpdated, exceptionMessage);
  }

  private consoleAPICalled(runtimeModel: RuntimeModel, event: Common.EventTarget.EventTargetEvent<ConsoleAPICall>):
      void {
    const call = event.data;
    let level: Protocol.Log.LogEntryLevel = Protocol.Log.LogEntryLevel.Info;
    if (call.type === Protocol.Runtime.ConsoleAPICalledEventType.Debug) {
      level = Protocol.Log.LogEntryLevel.Verbose;
    } else if (
        call.type === Protocol.Runtime.ConsoleAPICalledEventType.Error ||
        call.type === Protocol.Runtime.ConsoleAPICalledEventType.Assert) {
      level = Protocol.Log.LogEntryLevel.Error;
    } else if (call.type === Protocol.Runtime.ConsoleAPICalledEventType.Warning) {
      level = Protocol.Log.LogEntryLevel.Warning;
    } else if (
        call.type === Protocol.Runtime.ConsoleAPICalledEventType.Info ||
        call.type === Protocol.Runtime.ConsoleAPICalledEventType.Log) {
      level = Protocol.Log.LogEntryLevel.Info;
    }
    let message = '';
    if (call.args.length && call.args[0].unserializableValue) {
      message = call.args[0].unserializableValue;
    } else if (
        call.args.length &&
        ((typeof call.args[0].value !== 'object' && typeof call.args[0].value !== 'undefined') ||
         call.args[0].value === null)) {
      message = String(call.args[0].value);
    } else if (call.args.length && call.args[0].description) {
      message = call.args[0].description;
    }
    const callFrame = call.stackTrace?.callFrames.length ? call.stackTrace.callFrames[0] : null;
    const details = {
      type: call.type,
      url: callFrame?.url as Platform.DevToolsPath.UrlString | undefined,
      line: callFrame?.lineNumber,
      column: callFrame?.columnNumber,
      parameters: call.args,
      stackTrace: call.stackTrace,
      timestamp: call.timestamp,
      executionContextId: call.executionContextId,
      context: call.context,
    };
    const consoleMessage =
        new ConsoleMessage(runtimeModel, Common.Console.FrontendMessageSource.ConsoleAPI, level, (message), details);
    for (const msg of this.#messagesByTimestamp.get(consoleMessage.timestamp).values()) {
      if (consoleMessage.isEqual(msg)) {
        return;
      }
    }
    this.addMessage(consoleMessage);
  }

  private queryObjectRequested(
      runtimeModel: RuntimeModel, event: Common.EventTarget.EventTargetEvent<QueryObjectRequestedEvent>): void {
    const {objects, executionContextId} = event.data;
    const details = {
      type: FrontendMessageType.QueryObjectResult,
      parameters: [objects],
      executionContextId,
    };
    const consoleMessage = new ConsoleMessage(
        runtimeModel, Common.Console.FrontendMessageSource.ConsoleAPI, Protocol.Log.LogEntryLevel.Info, '', details);
    this.addMessage(consoleMessage);
  }

  private clearIfNecessary(): void {
    const settings = this.target().targetManager().settings;
    if (!settings.moduleSetting('preserve-console-log').get()) {
      this.clear();
    }
    ++this.#pageLoadSequenceNumber;
  }

  private primaryPageChanged(
      event: Common.EventTarget.EventTargetEvent<{frame: ResourceTreeFrame, type: PrimaryPageChangeType}>): void {
    const settings = this.target().targetManager().settings;
    if (settings.moduleSetting('preserve-console-log').get()) {
      const {frame} = event.data;
      if (frame.backForwardCacheDetails.restoredFromCache) {
        Common.Console.Console.instance().log(i18nString(UIStrings.bfcacheNavigation, {PH1: frame.url}));
      } else {
        Common.Console.Console.instance().log(i18nString(UIStrings.navigatedToS, {PH1: frame.url}));
      }
    }
  }

  private consoleProfileStarted(
      cpuProfilerModel: CPUProfilerModel, event: Common.EventTarget.EventTargetEvent<EventData>): void {
    const {data} = event;
    this.addConsoleProfileMessage(
        cpuProfilerModel, Protocol.Runtime.ConsoleAPICalledEventType.Profile, data.scriptLocation,
        i18nString(UIStrings.profileSStarted, {PH1: data.title}));
  }

  private consoleProfileFinished(
      cpuProfilerModel: CPUProfilerModel, event: Common.EventTarget.EventTargetEvent<EventData>): void {
    const {data} = event;
    this.addConsoleProfileMessage(
        cpuProfilerModel, Protocol.Runtime.ConsoleAPICalledEventType.ProfileEnd, data.scriptLocation,
        i18nString(UIStrings.profileSFinished, {PH1: data.title}));
  }

  private addConsoleProfileMessage(
      cpuProfilerModel: CPUProfilerModel, type: MessageType, scriptLocation: Location, messageText: string): void {
    const script = scriptLocation.script();
    const callFrames = [{
      functionName: '',
      scriptId: scriptLocation.scriptId,
      url: script ? script.contentURL() : '',
      lineNumber: scriptLocation.lineNumber,
      columnNumber: scriptLocation.columnNumber || 0,
    }];
    this.addMessage(new ConsoleMessage(
        cpuProfilerModel.runtimeModel(), Common.Console.FrontendMessageSource.ConsoleAPI,
        Protocol.Log.LogEntryLevel.Info, messageText, {type, stackTrace: {callFrames}}));
  }

  private incrementErrorWarningCount(msg: ConsoleMessage): void {
    if (msg.source === Protocol.Log.LogEntrySource.Violation) {
      this.#violations++;
      return;
    }
    switch (msg.level) {
      case Protocol.Log.LogEntryLevel.Warning:
        this.#warnings++;
        break;
      case Protocol.Log.LogEntryLevel.Error:
        this.#errors++;
        break;
    }
  }

  messages(): ConsoleMessage[] {
    return this.#messages;
  }

  // messages[] are not ordered by timestamp.
  static allMessagesUnordered(targetManager: TargetManager = TargetManager.instance()): ConsoleMessage[] {
    const messages = [];
    for (const target of targetManager.targets()) {
      const targetMessages = target.model(ConsoleModel)?.messages() || [];
      messages.push(...targetMessages);
    }
    return messages;
  }

  static requestClearMessages(targetManager: TargetManager = TargetManager.instance()): void {
    for (const logModel of targetManager.models(LogModel)) {
      logModel.requestClear();
    }
    for (const runtimeModel of targetManager.models(RuntimeModel)) {
      runtimeModel.discardConsoleEntries();
      // Runtime.discardConsoleEntries implies Runtime.releaseObjectGroup('console').
      runtimeModel.releaseObjectGroup('live-expression');
    }
    for (const target of targetManager.targets()) {
      target.model(ConsoleModel)?.clear();
    }
  }

  private clear(): void {
    this.#messages = [];
    this.#messagesByTimestamp.clear();
    this.#messageByExceptionId.clear();
    this.#errors = 0;
    this.#warnings = 0;
    this.#violations = 0;
    this.dispatchEventToListeners(Events.ConsoleCleared);
  }

  errors(): number {
    return this.#errors;
  }

  static allErrors(targetManager: TargetManager = TargetManager.instance()): number {
    let errors = 0;
    for (const target of targetManager.targets()) {
      errors += target.model(ConsoleModel)?.errors() || 0;
    }
    return errors;
  }

  warnings(): number {
    return this.#warnings;
  }

  static allWarnings(targetManager: TargetManager = TargetManager.instance()): number {
    let warnings = 0;
    for (const target of targetManager.targets()) {
      warnings += target.model(ConsoleModel)?.warnings() || 0;
    }
    return warnings;
  }

  violations(): number {
    return this.#violations;
  }

  async saveToTempVariable(currentExecutionContext: ExecutionContext|null, remoteObject: RemoteObject|null):
      Promise<void> {
    if (!remoteObject || !currentExecutionContext) {
      failedToSave(null);
      return;
    }
    const executionContext = (currentExecutionContext);

    const result = await executionContext.globalObject(/* objectGroup */ '', /* generatePreview */ false);
    if ('error' in result || Boolean(result.exceptionDetails) || !result.object) {
      failedToSave('object' in result && result.object || null);
      return;
    }

    const globalObject = result.object;
    const callFunctionResult =
        await globalObject.callFunction(saveVariable, [RemoteObject.toCallArgument(remoteObject)]);
    globalObject.release();
    if (callFunctionResult.wasThrown || callFunctionResult.object?.type !== 'string') {
      failedToSave(callFunctionResult.object || null);
    } else {
      const text = (callFunctionResult.object.value as string);
      const message = this.addCommandMessage(executionContext, text);
      void this.evaluateCommandInConsole(executionContext, message, text, /* useCommandLineAPI */ false);
    }
    if (callFunctionResult.object) {
      callFunctionResult.object.release();
    }

    function saveVariable(this: Window, value: Protocol.Runtime.CallArgument): string {
      const prefix = 'temp';
      let index = 1;
      while ((prefix + index) in this) {
        ++index;
      }
      const name = prefix + index;
      // @ts-expect-error Assignment to global object
      this[name] = value;
      return name;
    }

    function failedToSave(result: RemoteObject|null): void {
      let message = i18nString(UIStrings.failedToSaveToTempVariable);
      if (result) {
        message = (message + ' ' + result.description as Common.UIString.LocalizedString);
      }
      Common.Console.Console.instance().error(message);
    }
  }
}

export enum Events {
  /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
  ConsoleCleared = 'ConsoleCleared',
  MessageAdded = 'MessageAdded',
  MessageUpdated = 'MessageUpdated',
  CommandEvaluated = 'CommandEvaluated',
  /* eslint-enable @typescript-eslint/naming-convention */
}

export interface CommandEvaluatedEvent {
  result: RemoteObject;
  commandMessage: ConsoleMessage;
  exceptionDetails?: Protocol.Runtime.ExceptionDetails|undefined;
}

export interface EventTypes {
  [Events.ConsoleCleared]: void;
  [Events.MessageAdded]: ConsoleMessage;
  [Events.MessageUpdated]: ConsoleMessage;
  [Events.CommandEvaluated]: CommandEvaluatedEvent;
}

export interface AffectedResources {
  requestId?: Protocol.Network.RequestId;
  issueId?: Protocol.Audits.IssueId;
}

function extractExceptionMetaData(metaData?: {
  requestId?: Protocol.Network.RequestId,
  issueId?: Protocol.Audits.IssueId,
}): AffectedResources|undefined {
  if (!metaData) {
    return undefined;
  }
  return {requestId: metaData.requestId || undefined, issueId: metaData.issueId || undefined};
}

function areAffectedResourcesEquivalent(a?: AffectedResources, b?: AffectedResources): boolean {
  // Not considering issueId, as that would prevent de-duplication of console #messages.
  return a?.requestId === b?.requestId;
}

function areStackTracesEquivalent(
    stackTrace1?: Protocol.Runtime.StackTrace, stackTrace2?: Protocol.Runtime.StackTrace): boolean {
  if (!stackTrace1 !== !stackTrace2) {
    return false;
  }
  if (!stackTrace1 || !stackTrace2) {
    return true;
  }
  const callFrames1 = stackTrace1.callFrames;
  const callFrames2 = stackTrace2.callFrames;
  if (callFrames1.length !== callFrames2.length) {
    return false;
  }
  for (let i = 0, n = callFrames1.length; i < n; ++i) {
    if (callFrames1[i].scriptId !== callFrames2[i].scriptId ||
        callFrames1[i].functionName !== callFrames2[i].functionName ||
        callFrames1[i].lineNumber !== callFrames2[i].lineNumber ||
        callFrames1[i].columnNumber !== callFrames2[i].columnNumber) {
      return false;
    }
  }
  return areStackTracesEquivalent(stackTrace1.parent, stackTrace2.parent);
}

export interface ConsoleMessageDetails {
  type?: MessageType;
  url?: Platform.DevToolsPath.UrlString;
  line?: number;
  column?: number;
  parameters?: Array<string|RemoteObject|Protocol.Runtime.RemoteObject>;
  stackTrace?: Protocol.Runtime.StackTrace;
  timestamp?: number;
  executionContextId?: number;
  scriptId?: Protocol.Runtime.ScriptId;
  workerId?: string;
  context?: string;
  affectedResources?: AffectedResources;
  category?: Protocol.Log.LogEntryCategory;
}

export class ConsoleMessage {
  readonly #runtimeModel: RuntimeModel|null;
  source: MessageSource;
  level: Protocol.Log.LogEntryLevel|null;
  messageText: string;
  readonly type: MessageType;
  url: Platform.DevToolsPath.UrlString|undefined;
  line: number;
  column: number;
  parameters: Array<string|RemoteObject|Protocol.Runtime.RemoteObject>|undefined;
  stackTrace: Protocol.Runtime.StackTrace|undefined;
  timestamp: number;
  #executionContextId: number;
  scriptId?: Protocol.Runtime.ScriptId;
  workerId?: string;
  context?: string;
  #originatingConsoleMessage: ConsoleMessage|null = null;
  #pageLoadSequenceNumber?: number = undefined;
  #exceptionId?: number = undefined;
  #affectedResources?: AffectedResources;
  category?: Protocol.Log.LogEntryCategory;

  /**
   * The parent frame of the `console.log` call of logpoints or conditional breakpoints
   * if they called `console.*` explicitly. The parent frame is where V8 paused
   * and consequently where the logpoint is set.
   *
   * Is `null` for page console.logs, commands, command results, etc.
   */
  readonly stackFrameWithBreakpoint: Protocol.Runtime.CallFrame|null = null;
  readonly #originatingBreakpointType: BreakpointType|null = null;

  constructor(
      runtimeModel: RuntimeModel|null, source: MessageSource, level: Protocol.Log.LogEntryLevel|null,
      messageText: string, details?: ConsoleMessageDetails) {
    this.#runtimeModel = runtimeModel;
    this.source = source;
    this.level = (level);
    this.messageText = messageText;
    this.type = details?.type || Protocol.Runtime.ConsoleAPICalledEventType.Log;
    this.url = details?.url;
    this.line = details?.line || 0;
    this.column = details?.column || 0;
    this.parameters = details?.parameters;
    this.stackTrace = details?.stackTrace;
    this.timestamp = details?.timestamp || Date.now();
    this.#executionContextId = details?.executionContextId || 0;
    this.scriptId = details?.scriptId;
    this.workerId = details?.workerId;
    this.#affectedResources = details?.affectedResources;
    this.category = details?.category;

    if (!this.#executionContextId && this.#runtimeModel) {
      if (this.scriptId) {
        this.#executionContextId = this.#runtimeModel.executionContextIdForScriptId(this.scriptId);
      } else if (this.stackTrace) {
        this.#executionContextId = this.#runtimeModel.executionContextForStackTrace(this.stackTrace);
      }
    }

    if (details?.context) {
      const match = details?.context.match(/[^#]*/);
      this.context = match?.[0];
    }

    if (this.stackTrace) {
      const {callFrame, type} = ConsoleMessage.#stackFrameWithBreakpoint(this.stackTrace);
      this.stackFrameWithBreakpoint = callFrame;
      this.#originatingBreakpointType = type;
    }
  }

  getAffectedResources(): AffectedResources|undefined {
    return this.#affectedResources;
  }

  setPageLoadSequenceNumber(pageLoadSequenceNumber: number): void {
    this.#pageLoadSequenceNumber = pageLoadSequenceNumber;
  }

  static fromException(
      runtimeModel: RuntimeModel, exceptionDetails: Protocol.Runtime.ExceptionDetails,
      messageType?: Protocol.Runtime.ConsoleAPICalledEventType|FrontendMessageType, timestamp?: number,
      forceUrl?: Platform.DevToolsPath.UrlString, affectedResources?: AffectedResources): ConsoleMessage {
    const details = {
      type: messageType,
      url: forceUrl || exceptionDetails.url as Platform.DevToolsPath.UrlString,
      line: exceptionDetails.lineNumber,
      column: exceptionDetails.columnNumber,
      parameters: exceptionDetails.exception ?
          [RemoteObject.fromLocalObject(exceptionDetails.text), exceptionDetails.exception] :
          undefined,
      stackTrace: exceptionDetails.stackTrace,
      timestamp,
      executionContextId: exceptionDetails.executionContextId,
      scriptId: exceptionDetails.scriptId,
      affectedResources,
    };
    return new ConsoleMessage(
        runtimeModel, Protocol.Log.LogEntrySource.Javascript, Protocol.Log.LogEntryLevel.Error,
        RuntimeModel.simpleTextFromException(exceptionDetails), details);
  }

  runtimeModel(): RuntimeModel|null {
    return this.#runtimeModel;
  }

  target(): Target|null {
    return this.#runtimeModel ? this.#runtimeModel.target() : null;
  }

  setOriginatingMessage(originatingMessage: ConsoleMessage): void {
    this.#originatingConsoleMessage = originatingMessage;
    this.#executionContextId = originatingMessage.#executionContextId;
  }

  originatingMessage(): ConsoleMessage|null {
    return this.#originatingConsoleMessage;
  }

  setExecutionContextId(executionContextId: number): void {
    this.#executionContextId = executionContextId;
  }

  getExecutionContextId(): number {
    return this.#executionContextId;
  }

  getExceptionId(): number|undefined {
    return this.#exceptionId;
  }

  setExceptionId(exceptionId: number): void {
    this.#exceptionId = exceptionId;
  }

  isGroupMessage(): boolean {
    return this.type === Protocol.Runtime.ConsoleAPICalledEventType.StartGroup ||
        this.type === Protocol.Runtime.ConsoleAPICalledEventType.StartGroupCollapsed ||
        this.type === Protocol.Runtime.ConsoleAPICalledEventType.EndGroup;
  }

  isGroupStartMessage(): boolean {
    return this.type === Protocol.Runtime.ConsoleAPICalledEventType.StartGroup ||
        this.type === Protocol.Runtime.ConsoleAPICalledEventType.StartGroupCollapsed;
  }

  isErrorOrWarning(): boolean {
    return (this.level === Protocol.Log.LogEntryLevel.Warning || this.level === Protocol.Log.LogEntryLevel.Error);
  }

  isGroupable(): boolean {
    const isUngroupableError = this.level === Protocol.Log.LogEntryLevel.Error &&
        (this.source === Protocol.Log.LogEntrySource.Javascript || this.source === Protocol.Log.LogEntrySource.Network);
    return (
        this.source !== Common.Console.FrontendMessageSource.ConsoleAPI && this.type !== FrontendMessageType.Command &&
        this.type !== FrontendMessageType.Result && this.type !== FrontendMessageType.System && !isUngroupableError);
  }

  groupCategoryKey(): string {
    return [this.source, this.level, this.type, this.#pageLoadSequenceNumber].join(':');
  }

  isEqual(msg: ConsoleMessage|null): boolean {
    if (!msg) {
      return false;
    }

    if (this.parameters) {
      if (!msg.parameters || this.parameters.length !== msg.parameters.length) {
        return false;
      }

      for (let i = 0; i < msg.parameters.length; ++i) {
        const msgParam = msg.parameters[i];
        const param = this.parameters[i];
        if (typeof msgParam === 'string' || typeof param === 'string') {
          // TODO(chromium:1136435): Remove this case.
          return false;
        }
        if (msgParam.type === 'object' && msgParam.subtype !== 'error') {
          if (!msgParam.objectId || msgParam.objectId !== param.objectId || msg.timestamp !== this.timestamp) {
            return false;
          }
        }
        if (param.type !== msgParam.type || param.value !== msgParam.value ||
            param.description !== msgParam.description) {
          return false;
        }
      }
    }

    return (this.runtimeModel() === msg.runtimeModel()) && (this.source === msg.source) && (this.type === msg.type) &&
        (this.level === msg.level) && (this.line === msg.line) && (this.url === msg.url) &&
        (this.scriptId === msg.scriptId) && (this.messageText === msg.messageText) &&
        (this.#executionContextId === msg.#executionContextId) &&
        areAffectedResourcesEquivalent(this.#affectedResources, msg.#affectedResources) &&
        areStackTracesEquivalent(this.stackTrace, msg.stackTrace);
  }

  get originatesFromLogpoint(): boolean {
    return this.#originatingBreakpointType === BreakpointType.LOGPOINT;
  }

  /** @returns true, iff this was a console.* call in a conditional breakpoint */
  get originatesFromConditionalBreakpoint(): boolean {
    return this.#originatingBreakpointType === BreakpointType.CONDITIONAL_BREAKPOINT;
  }

  static #stackFrameWithBreakpoint({callFrames}: Protocol.Runtime.StackTrace):
      {callFrame: Protocol.Runtime.CallFrame|null, type: BreakpointType|null} {
    // Note that breakpoint condition code could in theory call into user JS and back into
    // "condition-defined" functions. This means that the top-most
    // stack frame is not necessarily the `console.log` call, but there could be other things
    // on top. We want the LAST marker frame in the stack.
    // We search FROM THE TOP for the last marked stack frame and
    // return it's parent (successor).
    const markerSourceUrls = [COND_BREAKPOINT_SOURCE_URL, LOGPOINT_SOURCE_URL];
    const lastBreakpointFrameIndex = callFrames.findLastIndex(({url}) => markerSourceUrls.includes(url));
    if (lastBreakpointFrameIndex === -1 || lastBreakpointFrameIndex === callFrames.length - 1) {
      // We either didn't find any breakpoint or we didn't capture enough stack
      // frames and the breakpoint condition is the bottom-most frame.
      return {callFrame: null, type: null};
    }

    const type = callFrames[lastBreakpointFrameIndex].url === LOGPOINT_SOURCE_URL ?
        BreakpointType.LOGPOINT :
        BreakpointType.CONDITIONAL_BREAKPOINT;
    return {callFrame: callFrames[lastBreakpointFrameIndex + 1], type};
  }
}

SDKModel.register(ConsoleModel, {capabilities: Capability.JS, autostart: true});

export type MessageSource = Protocol.Log.LogEntrySource|Common.Console.FrontendMessageSource;
export type MessageLevel = Protocol.Log.LogEntryLevel;
export type MessageType = Protocol.Runtime.ConsoleAPICalledEventType|FrontendMessageType;

export const MessageSourceDisplayName = new Map<MessageSource, string>(([
  [Protocol.Log.LogEntrySource.XML, 'xml'],
  [Protocol.Log.LogEntrySource.Javascript, 'javascript'],
  [Protocol.Log.LogEntrySource.Network, 'network'],
  [Common.Console.FrontendMessageSource.ConsoleAPI, 'console-api'],
  [Protocol.Log.LogEntrySource.Storage, 'storage'],
  [Protocol.Log.LogEntrySource.Appcache, 'appcache'],
  [Protocol.Log.LogEntrySource.Rendering, 'rendering'],
  [Common.Console.FrontendMessageSource.CSS, 'css'],
  [Protocol.Log.LogEntrySource.Security, 'security'],
  [Protocol.Log.LogEntrySource.Deprecation, 'deprecation'],
  [Protocol.Log.LogEntrySource.Worker, 'worker'],
  [Protocol.Log.LogEntrySource.Violation, 'violation'],
  [Protocol.Log.LogEntrySource.Intervention, 'intervention'],
  [Protocol.Log.LogEntrySource.Recommendation, 'recommendation'],
  [Protocol.Log.LogEntrySource.Other, 'other'],
  [Common.Console.FrontendMessageSource.ISSUE_PANEL, 'issue-panel'],
]));
